Compare commits

...

34 Commits

Author SHA1 Message Date
Timo
87f02b28a5 Merge pull request #3293 from element-hq/toger5/more-disconnect-logging (#3297)
Fix creating two lk rooms if there is no local store setup (fixes a resulting disconnect bug)
2025-05-28 09:07:50 +02:00
Timo
24cb61c24b Merge pull request #3291 from element-hq:toger5/backport-disable-device-switching-and-update-state
Backport: Disable device switching when in controlled audio devices mode
2025-05-23 17:56:49 +02:00
Timo
8f424452fc Disable device switching when in controlled audio devices mode (#3290)
* Disable device switching when in controlled audio devices mode

* Temporarily switch matrix-js-sdk to robin/embedded-no-update-state

To allow us to test this change on Element X, which does not yet support the update_state action.

* Also add a check for controlled audio devices in useAudioContext

* use develop branch

* fix tests

---------

Co-authored-by: Robin <robin@robin.town>
2025-05-23 17:55:28 +02:00
Robin
c2ce1fd382 Merge pull request #3288 from element-hq/robin/backport-audio-controls
Audio device controls for mobile native audio device selection
2025-05-22 14:32:12 -04:00
Robin
52895ed599 Audio device controls for mobile native audio device selection
Backport of 0971a15c40.
2025-05-22 14:11:46 -04:00
Robin
0719320ceb Merge pull request #3286 from element-hq/robin/backport-reset-develop
Reset to develop branch of matrix-js-sdk
2025-05-22 13:57:06 -04:00
Robin
593a50289a Reset to develop branch of matrix-js-sdk
Now that the toger5/add-room-key-fallback-on-encryption-manager-not-supported branch has been merged, we can reset to develop. (And need to, actually, because that branch is deleted.)
2025-05-22 13:12:23 -04:00
Robin
60881d7b11 Merge pull request #3281 from element-hq/robin/backport-reintroduce-update-state
Improve the reliability of state changes in widget mode
2025-05-21 14:36:24 -04:00
Robin
5f65c51cf3 Reference matrix-js-sdk by branch name
Rather than by commit, which makes it hard to tell whether we're using mainline matrix-js-sdk or not.
2025-05-21 14:27:15 -04:00
Robin
4876bddea4 Update matrix-widget-api to support update_state action 2025-05-21 14:27:15 -04:00
Robin
a5a737f830 Update matrix-js-sdk to support update_state action 2025-05-21 14:27:15 -04:00
Timo
8497f008c8 Bump js-sdk to support to-device fallback (#3277) 2025-05-19 19:27:08 +02:00
Timo
28246ef86b Update RTCSession configuration to non deprecated names. (#3267)
* Update RTCSession configuration to non deprecated names.

* bump js-sdk

* es2024 and array buffer adaptions

* Alternative without `createKeyMaterialFromBuffer`
2025-05-19 18:04:07 +02:00
Timo
0110465b38 Docker-compose: do not access ports <443 (#3261)
* Do not try accessing ports <1024

* adapt nginx reverse proxy for element web

---------

Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-05-19 14:18:03 +02:00
fkwp
cc8ab0ac93 Merge pull request #3241 from element-hq/renovate/github-actions
Update GitHub Actions
2025-05-16 09:57:30 +02:00
renovate[bot]
da2ce68348 Update GitHub Actions 2025-05-15 23:01:56 +00:00
ElementRobot
471917b16a Merge pull request #3273 from element-hq/actions/localazy-download
Localazy Download
2025-05-15 15:15:05 -05:00
fkwp
f6870ae5b4 Translations updates 2025-05-15 20:10:41 +00:00
Timo
b5fe55aef2 Add custom audio renderer for iPhone earpiece and only render joined participants (#3249)
* Add custom audio renderer to only render joined participants & add ios earpice workaround

fix left right to match chromium + safari
(firefox is swapped)

earpice as setting

Simpler code and documentation
The doc explains, what this class actually does and why it is so complicated.

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

use only one audioContext, remove (non working) standby fallback

* Add tests

* use optional audio context and effect to initiate it + review
2025-05-15 20:46:39 +02:00
Valere Fedronic
86d80630c1 Fix connection leaks: Ensure that any pending connection open are cancelled/undo when ActiveCall is unmounted (#3255)
* Better logs for connection/component lifecycle

* fix: `AudioCaptureOptions` was causing un-necessary effect render

AudioCaptureOptions was a different object but with same internal values, use directly deviceId so that Object.is works properly

* fix: Livekit openned connection leaks

* review: rename to AbortHandles

* review: rename variable

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-05-14 18:41:22 +02:00
renovate[bot]
4569d01353 Update dependency vite to v6.2.7 [SECURITY] (#3240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-14 12:09:53 +02:00
Valere Fedronic
1ff2fb3fff only show to-device transport label in dev mode (#3221)
* Only show to device encryption label if developer mode on

* Add tests for developer mode to device label

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-05-13 22:22:56 +02:00
Timo
e6710f72e3 Fix to-device encryption info label (#3208)
* Fix to-device encryption info label
The label was shown also without checking that we use PerParticipantE2EE. Which is a prerequisite for toDevice transport. As a result the label was shown when not desired.

* rename: useLiveKit -> useLivekit

* make the settings naming consistent
2025-05-13 22:05:55 +02:00
Timo
04bc6c77a3 developer option to mute all audio (#3239) 2025-05-13 21:11:12 +02:00
fkwp
34b3b9d733 Merge pull request #3265 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.12.0
2025-05-13 09:44:26 +02:00
fkwp
44ed04475d bump livekit protocol version 2025-05-13 09:38:43 +02:00
renovate[bot]
de8a2cb533 Update dependency livekit-client to v2.12.0 2025-05-13 07:25:55 +00:00
fkwp
4344f1dd1e Merge pull request #3260 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.11.4
2025-05-13 09:23:49 +02:00
renovate[bot]
9f8dd000e0 Update embedded package dependencies (#3242)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 14:20:03 +02:00
renovate[bot]
b681e53c35 Update dependency livekit-client to v2.11.4 2025-05-12 01:54:04 +00:00
Robin
6281628e4e Merge pull request #3258 from element-hq/robin/webcodecs-update
Remove patched version of WebCodecs types
2025-05-09 10:54:23 -04:00
Robin
9f727b904b Remove patched version of WebCodecs types
The patch has now been upstreamed to @types/dom-webcodecs.
2025-05-09 10:48:09 -04:00
Quentin Gliech
380880137e Keep the HTML lang attribute in sync with the i18next language (#3247) 2025-05-05 17:12:00 +02:00
fkwp
8828b705d9 Merge pull request #3235 from element-hq/fkwp/refactor_dev_backend
DevX: Properly server .well-known and use endpoint routing as described in self-hosting.md
2025-05-05 16:30:02 +02:00
75 changed files with 2861 additions and 1322 deletions

View File

@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 📥 Download artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ inputs.artifact_run_id }}
@@ -53,7 +53,7 @@ jobs:
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Build and push Docker image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -31,7 +31,7 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"

View File

@@ -7,7 +7,7 @@ jobs:
pr-changelog-label:
runs-on: ubuntu-latest
steps:
- uses: yogevbd/enforce-label-action@2.1.0
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
with:
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies"
REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label"

View File

@@ -46,7 +46,7 @@ jobs:
Exercise caution. Use test accounts.
- name: 📥 Download artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ inputs.artifact_run_id }}

View File

@@ -11,7 +11,7 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"

View File

@@ -71,7 +71,7 @@ jobs:
- name: Determine filename
run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -83,7 +83,7 @@ jobs:
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -104,7 +104,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -113,7 +113,7 @@ jobs:
# n.b. We don't enable corepack here because we are using plain npm
- name: Setup node
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: .node-version
registry-url: "https://registry.npmjs.org"
@@ -145,7 +145,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -153,7 +153,7 @@ jobs:
path: embedded/android/lib/src/main/assets/element-call
- name: ☕️ Setup Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
with:
distribution: "temurin"
java-version: "17"
@@ -200,7 +200,7 @@ jobs:
path: element-call
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -260,7 +260,7 @@ jobs:
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
- name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
append_body: true
body: |

View File

@@ -31,7 +31,7 @@ jobs:
- name: Determine filename
run: echo "FILENAME_PREFIX=element-call-${VERSION:1}" >> "$GITHUB_ENV"
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -42,7 +42,7 @@ jobs:
- name: Create Checksum
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -68,7 +68,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add release note
uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
append_body: true
body: |

View File

@@ -13,7 +13,7 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"
@@ -22,7 +22,7 @@ jobs:
- name: Vitest
run: "yarn run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -36,7 +36,7 @@ jobs:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"

View File

@@ -18,7 +18,7 @@ jobs:
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"

View File

@@ -145,11 +145,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://element-web:81;
proxy_pass http://element-web:8081;
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}
}

View File

@@ -73,11 +73,11 @@ services:
image: ghcr.io/element-hq/element-web:develop
pull_policy: always
volumes:
- ./backend/ew.test.config.json:/app/config.json
- ./backend/ew.test.config.json:/app/config.json:Z
environment:
ELEMENT_WEB_PORT: 81
ELEMENT_WEB_PORT: 8081
ports:
- "8081:81"
- "8081:8081"
networks:
- ecbackend
@@ -91,7 +91,6 @@ services:
- ./backend/dev_tls_m.localhost.crt:/root/ssl/cert.pem:Z
ports:
# HOST_PORT:CONTAINER_PORT
- "80:80"
- "443:443"
- "8008:80"
- "4443:443"

View File

@@ -1,7 +1,21 @@
# Global JS controls
A few aspects of Element Call's interface can be controlled through a global API on the `window`:
A few aspects of Element Call's interface can be controlled through a global API on the `window`.
## Picture-in-picture
- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call.
## Audio devices
On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect.
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device.
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.
- `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")`)

View File

@@ -63,6 +63,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |

View File

@@ -2,11 +2,11 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
android_gradle_plugin = "8.8.0"
android_gradle_plugin = "8.10.0"
[libraries]
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
[plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.30.0" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.31.0" }

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

View File

@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

View File

@@ -27,6 +27,10 @@ export default {
// then Knip will flag it as a false positive
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
// We need this so that TypeScript is happy with @livekit/track-processors.
// This might be a bug in the LiveKit repo but for now we fix it on the
// Element Call side.
"@types/dom-mediacapture-transform",
"matrix-widget-api",
],
ignoreExportsUsedInFile: true,

View File

@@ -70,10 +70,12 @@
"livekit_server_info": "Informace o serveru LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Ztlumit všechny zvuky (účastníci, reakce, zvuky připojení)",
"show_connection_stats": "Zobrazit statistiky připojení",
"show_non_member_tiles": "Zobrazit dlaždice pro nečlenská média",
"url_params": "Parametry URL",
"use_new_membership_manager": "Použijte novou implementaci volání MembershipManager"
"use_new_membership_manager": "Použijte novou implementaci volání MembershipManager",
"use_to_device_key_transport": "Použít přenos klíčů do zařízení. Tím se vrátíte k přenosu klíčů do místnosti, když jiný účastník hovoru pošle klíč místnosti"
},
"disconnected_banner": "Připojení k serveru bylo ztraceno.",
"error": {
@@ -164,6 +166,9 @@
"effect_volume_description": "Upravit hlasitost přehrávání reakcí a efektů zvednutých rukou.",
"effect_volume_label": "Hlasitost zvukového efektu"
},
"background_blur_header": "Pozadí",
"background_blur_label": "Rozostřit pozadí videa",
"blur_not_supported_by_browser": "(Toto zařízení nepodporuje rozostření pozadí.)",
"developer_tab_title": "Vývojář",
"devices": {
"camera": "Fotoaparát",

221
locales/da/app.json Normal file
View File

@@ -0,0 +1,221 @@
{
"a11y": {
"user_menu": "Brugermenu"
},
"action": {
"close": "Luk",
"copy_link": "Kopiér link",
"edit": "Rediger",
"go": "Gå",
"invite": "Invitér",
"lower_hand": "Sænk hånd",
"no": "Nej",
"pick_reaction": "Vælg reaktion",
"raise_hand": "Ræk hånden op",
"register": "Registrér",
"remove": "Fjern",
"show_less": "Vis mindre",
"show_more": "Vis mere",
"sign_in": "Log ind",
"sign_out": "Log ud",
"submit": "Indsend",
"upload_file": "Upload fil"
},
"analytics_notice": "Ved at deltage i denne beta giver du samtykke til indsamling af anonyme data, som vi bruger til at forbedre produktet. Du kan finde flere oplysninger om, hvilke data vi sporer, i vores <2>fortrolighedspolitik</2> og vores <6>cookiepolitik</6>.",
"app_selection_modal": {
"continue_in_browser": "Fortsæt i browseren",
"open_in_app": "Åbn i appen",
"text": "Klar til at deltage?",
"title": "Vælg app"
},
"call_ended_view": {
"create_account_button": "Opret konto",
"create_account_prompt": "<0>Hvorfor ikke afslutte med at oprette en adgangskode for at beholde din konto? </0><1>Du kan beholde dit navn og indstille en avatar til brug ved fremtidige opkald </1>",
"feedback_done": "<0>Tak for din feedback! </0>",
"feedback_prompt": "<0>Vi vil meget gerne høre din feedback, så vi kan forbedre din oplevelse.</0>",
"headline": "{{displayName}}, dit opkald er afsluttet.",
"not_now_button": "Ikke nu, vend tilbage til startskærmen",
"reconnect_button": "Tilslut igen",
"survey_prompt": "Hvordan gik det?"
},
"call_name": "Navn på opkald",
"common": {
"analytics": "Analyse-værktøj",
"audio": "Lyd",
"avatar": "Avatar",
"back": "Tilbage",
"display_name": "Vist navn",
"encrypted": "Krypteret",
"home": "Hjem",
"loading": "Indlæser...",
"next": "Næste",
"options": "Valgmuligheder",
"password": "Adgangskode",
"preferences": "Foretrukne",
"profile": "Profil",
"reaction": "Reaktion",
"reactions": "Reaktioner",
"settings": "Indstillinger",
"unencrypted": "Ikke krypteret",
"username": "Brugernavn",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Krypto-version: {{version}}",
"debug_tile_layout_label": "Fejlfinding af fliselayout",
"device_id": "Enheds-id: {{id}}",
"duplicate_tiles_label": "Antal ekstra flisekopier pr. deltager",
"environment_variables": "Miljøvariabler",
"hostname": "Værtsnavn: {{hostname}}",
"livekit_server_info": "LiveKit Serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"show_connection_stats": "Vis forbindelsesstatistik",
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
"url_params": "URL-parametre",
"use_new_membership_manager": "Brug den nye implementering af opkaldet MembershipManager",
"use_to_device_key_transport": "Bruges til at transportere enhedsnøgler. Dette vil falde tilbage til transport af værelsesnøgler, når et andet opkaldsmedlem sender en rumnøgle"
},
"disconnected_banner": "Forbindelsen til serveren er gået tabt.",
"error": {
"call_is_not_supported": "Opkald er ikke understøttet",
"call_not_found": "Opkald ikke fundet",
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt</1>.</0>",
"connection_lost": "Forbindelsen gik tabt",
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
"e2ee_unsupported": "Inkompatibel browser",
"e2ee_unsupported_description": "Din webbrowser understøtter ikke krypterede opkald. Understøttede browsere inkluderer Chrome, Safari og Firefox 117+.",
"generic": "Noget gik galt",
"generic_description": "Indsendelse af fejlfindingslogfiler hjælper os med at spore problemet.",
"insufficient_capacity": "Utilstrækkelig kapacitet",
"insufficient_capacity_description": "Serveren har nået sin maksimale kapacitet, og du kan ikke deltage i opkaldet på dette tidspunkt. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.",
"matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).",
"open_elsewhere": "Åbnet i en anden fane",
"open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.",
"unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode:</0> <1> {{ errorCode }}</1>). Kontakt venligst din serveradministrator."
},
"group_call_loader": {
"banned_body": "Du er blevet spærret fra rummet.",
"banned_heading": "Spærret",
"call_ended_body": "Du er blevet fjernet fra opkaldet.",
"call_ended_heading": "Opkaldet afsluttet",
"knock_reject_body": "Din anmodning om at deltage blev afvist.",
"knock_reject_heading": "Adgang nægtet",
"reason": "Årsag: {{reason}}"
},
"hangup_button_label": "Afslut opkald",
"header_label": "Element Ring hjem",
"header_participants_label": "Deltagere",
"invite_modal": {
"link_copied_toast": "Link kopieret til udklipsholder",
"title": "Inviter til dette opkald"
},
"join_existing_call_modal": {
"join_button": "Ja, deltag i opkald",
"text": "Dette opkald findes allerede, vil du være med?",
"title": "Deltag i eksisterende opkald?"
},
"layout_grid_label": "Gitter",
"layout_spotlight_label": "Spotlys",
"lobby": {
"ask_to_join": "Anmod om at deltage i opkaldet",
"join_as_guest": "Deltag som gæst",
"join_button": "Deltag i opkald",
"leave_button": "Tilbage til seneste",
"waiting_for_invite": "Anmodning sendt! Venter på tilladelse til at deltage..."
},
"log_in": "Log ind",
"logging_in": "Logger ind...",
"login_auth_links": "<0>Opret en konto </0> eller <2> få adgang som gæst </2>",
"login_auth_links_prompt": "Ikke registreret endnu?",
"login_subheading": "For at fortsætte til Element",
"login_title": "Login",
"microphone_off": "Mikrofon slukket",
"microphone_on": "Mikrofon tændt",
"mute_microphone_button_label": "Slå mikrofonen fra",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "QR-kode",
"rageshake_button_error_caption": "Prøv at sende logfiler igen",
"rageshake_request_modal": {
"body": "En anden bruger på dette opkald har et problem. For bedre at kunne diagnosticere sådanne problemer, vil vi gerne indsamle en fejlfindingslog.",
"title": "Anmodning om fejlfindingslogfil"
},
"rageshake_send_logs": "Send fejlfindingslogfiler",
"rageshake_sending": "Sender...",
"rageshake_sending_logs": "Afsendelse af fejlfindingslogfiler...",
"rageshake_sent": "Tak!",
"recaptcha_dismissed": "Recaptcha afvist",
"recaptcha_not_loaded": "Recaptcha ikke indlæst",
"recaptcha_ssla_caption": "Dette websted er beskyttet af ReCAPTCHA og Googles <2>Privatlivspolitik</2> og <6>Servicevilkår</6> gælder.<9></9>Ved at klikke på \"Registrer\" accepterer du vores <12>Software og Services Licensaftale (SSLA)</12>",
"register": {
"passwords_must_match": "Adgangskoderne skal være identiske",
"registering": "Registrering..."
},
"register_auth_links": "<0>Har du allerede en konto? </0><1><0>Log ind </0> eller <2> få adgang som gæst </2> </1>",
"register_confirm_password_label": "Bekræft adgangskode",
"register_heading": "Opret din konto",
"return_home_button": "Vend tilbage til startskærmen",
"room_auth_view_continue_button": "Fortsæt",
"room_auth_view_ssla_caption": "Ved at klikke på „Deltag i opkald nu“ accepterer du vores <2> Software og Services Licensaftale (SSLA) </2>",
"screenshare_button_label": "Del din skærm",
"settings": {
"audio_tab": {
"effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.",
"effect_volume_label": "Lydstyrke for lydeffekter"
},
"developer_tab_title": "Udvikler",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Standard",
"default_named": "Standard <2>({{name}})</2>",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Højttaler",
"speaker_numbered": "Højttaler {{n}}"
},
"feedback_tab_body": "Hvis du oplever problemer eller bare gerne vil give feedback, kan du sende os en kort beskrivelse herunder.",
"feedback_tab_description_label": "Din tilbagemelding",
"feedback_tab_h4": "Indsend feedback",
"feedback_tab_send_logs_label": "Medtag fejlfindingslogfiler",
"feedback_tab_thank_you": "Tak, vi har modtaget din feedback!",
"feedback_tab_title": "Feedback",
"opt_in_description": "<0></0><1></1>Du kan trække dit samtykke tilbage ved at fjerne markeringen i dette felt. Hvis du i øjeblikket er i gang med et opkald, træder denne indstilling i kraft ved afslutningen af opkaldet.",
"preferences_tab": {
"developer_mode_label": "Udviklertilstand",
"developer_mode_label_description": "Aktivér udviklertilstand og vis fanen udviklerindstillinger.",
"introduction": "Her kan du konfigurere ekstra muligheder for en forbedret oplevelse.",
"reactions_play_sound_description": "Afspil en lydeffekt, når nogen sender en reaktion i et opkald.",
"reactions_play_sound_label": "Afspil reaktionslyde",
"reactions_show_description": "Vis en animation, når nogen sender en reaktion.",
"reactions_show_label": "Vis reaktioner",
"show_hand_raised_timer_description": "Vis en timer, når en deltager rækker hånden",
"show_hand_raised_timer_label": "Vis varighed af håndsoprækning"
}
},
"star_rating_input_label_one": "{{count}} stjerne",
"star_rating_input_label_other": "{{count}} stjerner",
"start_new_call": "Start nyt opkald",
"start_video_button_label": "Start video",
"stop_screenshare_button_label": "Skærmen bliver delt",
"stop_video_button_label": "Stop video",
"submitting": "Indsender...",
"switch_camera": "Skift kamera",
"unauthenticated_view_body": "Ikke registreret endnu? <2>Opret en konto </2>",
"unauthenticated_view_login_button": "Log ind på din konto",
"unauthenticated_view_ssla_caption": "Ved at klikke på \"Start\" accepterer du vores <2>Software og Services Licensaftale (SSLA)</2>",
"unmute_microphone_button_label": "Slå mikrofonen til",
"version": "{{productName}} version: {{version}}",
"video_tile": {
"always_show": "Vis altid",
"camera_starting": "Indlæser video",
"change_fit_contain": "Tilpas til rammen",
"collapse": "Fold sammen",
"expand": "Udvid",
"mute_for_me": "Slå lyden fra for mig",
"muted_for_me": "Dæmpet for mig",
"volume": "Lydstyrke",
"waiting_for_media": "Venter på medier..."
}
}

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "iPhone-Ohrhörer-Option auf allen Plattformen anzeigen",
"crypto_version": "Krypto-Version: {{version}}",
"debug_tile_layout_label": "Kachel-Layout debuggen",
"device_id": "Geräte-ID: {{id}}",
@@ -70,10 +71,12 @@
"livekit_server_info": "LiveKit-Server Informationen",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix-ID: {{id}}",
"mute_all_audio": "Stummschalten aller Audiosignale (Teilnehmer, Reaktionen, Beitrittsgeräusche)",
"show_connection_stats": "Verbindungsstatistiken anzeigen",
"show_non_member_tiles": "Kacheln für Nicht-Mitgliedermedien anzeigen",
"url_params": "URL-Parameter",
"use_new_membership_manager": "Neuen MembershipManager verwenden"
"use_new_membership_manager": "Neuen MembershipManager verwenden",
"use_to_device_key_transport": "To-Device media E2EE Schlüssel-Transport verwenden. Falls ein anderer Teilnehmer bereits den Raumschlüssel-Transport verwendet, wird automatisch auf Raumschlüssel-Transport zurückgegriffen."
},
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
"error": {
@@ -163,12 +166,16 @@
"effect_volume_description": "Lautstärke anpassen, mit der Reaktionen und Handmeldungen abgespielt werden.",
"effect_volume_label": "Lautstärke der Soundeffekte"
},
"background_blur_header": "Hintergrund",
"background_blur_label": "Unschärfeeffekt für den Hintergrund aktivieren",
"blur_not_supported_by_browser": "(Hintergrundunschärfe wird von diesem Gerät nicht unterstützt.)",
"developer_tab_title": "Entwickler",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Standard",
"default_named": "Standard<2> ({{name}} )</2>",
"earpiece": "Ohrhörer",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon{{n}}",
"speaker": "Lautsprecher",

View File

@@ -4,15 +4,30 @@
},
"action": {
"close": "Κλείσιμο",
"copy_link": "Αντιγραφή συνδέσμου",
"edit": "Επεξεργασία",
"go": "Μετάβαση",
"invite": "Πρόσκληση",
"lower_hand": "Κατεβάστε το χέρι",
"no": "Όχι",
"pick_reaction": "Επιλέξτε αντίδραση",
"raise_hand": "Σηκώστε το χέρι",
"register": "Εγγραφή",
"remove": "Αφαίρεση",
"show_less": "Εμφάνιση λιγότερων",
"show_more": "Εμφάνιση περισσότερων",
"sign_in": "Σύνδεση",
"sign_out": "Αποσύνδεση",
"submit": "Υποβολή"
"submit": "Υποβολή",
"upload_file": "Μεταφόρτωση αρχείου"
},
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <6>Πολιτική cookies</6>.",
"app_selection_modal": {
"continue_in_browser": "Συνέχεια στο πρόγραμμα περιήγησης",
"open_in_app": "Ανοίξτε στην εφαρμογή",
"text": "Έτοιμοι να συμμετάσχετε?",
"title": "Επιλέξτε εφαρμογή"
},
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <5>Πολιτική cookies</5>.",
"call_ended_view": {
"create_account_button": "Δημιουργία λογαριασμού",
"create_account_prompt": "<0>Γιατί να μην ολοκληρώσετε με τη δημιουργία ενός κωδικού πρόσβασης για τη διατήρηση του λογαριασμού σας;</0><1>Θα μπορείτε να διατηρήσετε το όνομά σας και να ορίσετε ένα avatar για χρήση σε μελλοντικές κλήσεις.</1>",
@@ -20,19 +35,45 @@
"feedback_prompt": "<0>Θα θέλαμε να ακούσουμε τα σχόλιά σας ώστε να βελτιώσουμε την εμπειρία σας.</0>",
"headline": "{{displayName}}, η κλήση σας τερματίστηκε.",
"not_now_button": "Όχι τώρα, επιστροφή στην αρχική οθόνη",
"reconnect_button": "Επανασύνδεση",
"survey_prompt": "Πώς σας φάνηκε;"
},
"call_name": "Όνομα κλήσης",
"common": {
"analytics": "Δεδομένα ανάλυσης",
"audio": "Ήχος",
"avatar": "Εικόνα Προφίλ",
"back": "Πίσω",
"display_name": "Εμφανιζόμενο όνομα",
"encrypted": "Κρυπτογραφημένο",
"home": "Αρχική",
"loading": "Φόρτωση…",
"next": "Επόμενο",
"options": "Επιλογές",
"password": "Κωδικός",
"preferences": "Προτιμήσεις",
"profile": "Προφίλ",
"reaction": "Αντίδραση",
"reactions": "Αντιδράσεις",
"settings": "Ρυθμίσεις",
"unencrypted": "Μη κρυπτογραφημένο",
"username": "Όνομα χρήστη",
"video": "Βίντεο"
},
"developer_mode": {
"crypto_version": "Έκδοση κρυπτογράφησης: {{version}}",
"debug_tile_layout_label": "Διάταξη πλακιδίων εντοπισμού σφαλμάτων",
"device_id": "Αναγνωριστικό συσκευής: {{id}}",
"duplicate_tiles_label": "Αριθμός επιπλέον αντιγράφων πλακιδίων ανά συμμετέχοντα",
"environment_variables": "Μεταβλητές περιβάλλοντος",
"hostname": "Όνομα κεντρικού υπολογιστή: {{hostname}}",
"livekit_server_info": "Πληροφορίες διακομιστή LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Αναγνωριστικό Matrix: {{id}}",
"show_connection_stats": "Εμφάνιση στατιστικών σύνδεσης",
"show_non_member_tiles": "Εμφάνιση πλακιδίων για μέσα μη-μελών",
"url_params": "Παράμετροι URL"
},
"header_label": "Element Κεντρική Οθόνη Κλήσεων",
"join_existing_call_modal": {
"join_button": "Ναι, συμμετοχή στην κλήση",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
"debug_tile_layout_label": "Debug tile layout",
"device_id": "Device ID: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
"show_connection_stats": "Show connection statistics",
"show_non_member_tiles": "Show tiles for non-member media",
"url_params": "URL parameters",
@@ -171,8 +173,10 @@
"devices": {
"camera": "Camera",
"camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"earpiece": "Earpiece",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker",

View File

@@ -70,10 +70,12 @@
"livekit_server_info": "LiveKiti serveri teave",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrixi kasutajatunnus: {{id}}",
"mute_all_audio": "Summuta kõik helid (osalejad, regeerimised, liitumise helid)",
"show_connection_stats": "Näita ühenduse statistikat",
"show_non_member_tiles": "Näita ka mitteseotud meedia paane",
"url_params": "Võrguaadressi parameetrid",
"use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni"
"use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni",
"use_to_device_key_transport": "Kasuta seadmepõhist krüptovõtmete vahetust. Kui jututoa liige peaks saatma jututoakohase krüptovõtme, siis kasuta jututoakohast võtmevahetust"
},
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
"error": {
@@ -163,6 +165,9 @@
"effect_volume_description": "Häälesta helivaljust, mida kasutatakse käe tõstmisel ja regeerimisel",
"effect_volume_label": "Efektide helivajlus"
},
"background_blur_header": "Taust",
"background_blur_label": "Hägusta video taust",
"blur_not_supported_by_browser": "(Tausta hägustamine pole selles seadmes toetatud.)",
"developer_tab_title": "Arendaja",
"devices": {
"camera": "Kaamera",

224
locales/fi/app.json Normal file
View File

@@ -0,0 +1,224 @@
{
"a11y": {
"user_menu": "Käyttäjävalikko"
},
"action": {
"close": "Sulje",
"copy_link": "Kopioi linkki",
"edit": "Muokkaa",
"go": "Siirry",
"invite": "Kutsu",
"lower_hand": "Laske käsi",
"no": "Ei",
"pick_reaction": "Valitse reaktio",
"raise_hand": "Nosta käsi",
"register": "Rekisteröidy",
"remove": "Poista",
"show_less": "Näytä vähemmän",
"show_more": "Näytä lisää",
"sign_in": "Kirjaudu sisään",
"sign_out": "Kirjaudu ulos",
"submit": "Lähetä",
"upload_file": "Lähetä tiedosto"
},
"analytics_notice": "Osallistumalla tähän betaan hyväksyt nimettömien tietojen keräämisen, joita käytämme tuotteen parantamiseen. Löydät lisätietoa siitä, mitä tietoja seuraamme meidän <2> Tietosuojakäytännöstä</2> ja <6>Evästekäytännöstä</6> .",
"app_selection_modal": {
"continue_in_browser": "Jatka selaimessa",
"open_in_app": "Avaa sovelluksessa",
"text": "Oletko valmis liittymään?",
"title": "Valitse sovellus"
},
"call_ended_view": {
"create_account_button": "Luo tili",
"create_account_prompt": "<0>Miksi et viimeistelisi määrittämällä salasanaa tilisi säilyttämiseksi?</0><1>Voit säilyttää nimesi ja asettaa avatarin käytettäväksi tulevissa puheluissa</1>",
"feedback_done": "<0>Kiitos palautteestasi!</0>",
"feedback_prompt": "<0>Haluaisimme kuulla palautteesi, jotta voimme parantaa käyttökokemustasi.</0>",
"headline": "{{displayName}}, puhelusi on päättynyt.",
"not_now_button": "Ei nyt, palaa aloitusnäyttöön",
"reconnect_button": "Yhdistä uudelleen",
"survey_prompt": "Miten se meni?"
},
"call_name": "Puhelun nimi",
"common": {
"analytics": "Analytiikka",
"audio": "Ääni",
"avatar": "Avatar",
"back": "Takaisin",
"display_name": "Näyttönimi",
"encrypted": "Salattu",
"home": "Etusivu",
"loading": "Ladataan...",
"next": "Seuraava",
"options": "Vaihtoehdot",
"password": "Salasana",
"preferences": "Asetukset",
"profile": "Profiili",
"reaction": "Reaktio",
"reactions": "Reaktiot",
"settings": "Asetukset",
"unencrypted": "Ei salattu",
"username": "Käyttäjänimi",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Kryptoversio: {{version}}",
"debug_tile_layout_label": "Laattojen asettelun vianmääritys",
"device_id": "Laitteen tunnus: {{id}}",
"duplicate_tiles_label": "Lisälaattakopioiden määrä osallistujaa kohti",
"environment_variables": "Ympäristömuuttujat",
"hostname": "Isäntänimi: {{hostname}}",
"livekit_server_info": "LiveKit-palvelimen tiedot",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix tunnus: {{id}}",
"show_connection_stats": "Näytä yhteystilastot",
"show_non_member_tiles": "Näytä laatat ei-jäsenien medialle",
"url_params": "URL-parametrit",
"use_new_membership_manager": "Käytä uutta puhelun MembershipManagerin toteutusta",
"use_to_device_key_transport": "Käytä laitteen avainten kuljetusta. Tämä palaa huoneen avainten siirtoon, kun toinen puhelun jäsen lähettää huoneavaimen"
},
"disconnected_banner": "Yhteys palvelimeen on katkennut.",
"error": {
"call_is_not_supported": "Puhelua ei tueta",
"call_not_found": "Puhelua ei löydy",
"call_not_found_description": "<0>Kyseinen linkki ei näytä kuuluvan mihinkään olemassa olevaan puheluun. Tarkista, että sinulla on oikea linkki, tai <1>luo uusi linkki</1>.</0>",
"connection_lost": "Yhteys katkesi",
"connection_lost_description": "Sinut katkaistiin puhelusta.",
"e2ee_unsupported": "Yhteensopimaton selain",
"e2ee_unsupported_description": "Verkkoselaimesi ei tue salattuja puheluita. Tuettuja selaimia ovat Chrome, Safari ja Firefox 117+.",
"generic": "Jokin meni pieleen",
"generic_description": "Vianmäärityslokien lähettäminen auttaa meitä jäljittämään ongelman.",
"insufficient_capacity": "Riittämätön kapasiteetti",
"insufficient_capacity_description": "Palvelin on saavuttanut maksimikapasiteettinsa, etkä voi liittyä puheluun tällä hetkellä. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
"matrix_rtc_focus_missing": "Palvelinta ei ole määritetty toimimaan {{brand}} -sovelluksen kanssa. Ota yhteyttä palvelimen ylläpitäjään (Verkkotunnus: {{domain}}, Virhekoodi: {{ errorCode }}).",
"open_elsewhere": "Avattu toisessa välilehdessä",
"open_elsewhere_description": "{{brand}} on avattu toisessa välilehdessä. Jos tämä ei kuulosta oikealta, yritä ladata sivu uudelleen.",
"unexpected_ec_error": "Tapahtui odottamaton virhe (<0>Virhekoodi:</0> <1>{{ errorCode }}</1>). Ota yhteyttä palvelimen ylläpitäjään."
},
"group_call_loader": {
"banned_body": "Sinulle on annettu porttikielto huoneesta.",
"banned_heading": "Kielletty",
"call_ended_body": "Sinut on poistettu puhelusta.",
"call_ended_heading": "Puhelu päättyi",
"knock_reject_body": "Liittymispyyntösi hylättiin.",
"knock_reject_heading": "Pääsy kielletty",
"reason": "Syy: {{reason}}"
},
"hangup_button_label": "Lopeta puhelu",
"header_label": "Element Call Etusivu",
"header_participants_label": "Osallistujat",
"invite_modal": {
"link_copied_toast": "Linkki kopioitu leikepöydälle",
"title": "Kutsu tähän puheluun"
},
"join_existing_call_modal": {
"join_button": "Kyllä, liity puheluun",
"text": "Tämä puhelu on jo olemassa, haluatko liittyä siihen?",
"title": "Liity olemassa olevaan puheluun?"
},
"layout_grid_label": "Ruudukko",
"layout_spotlight_label": "Valokeila",
"lobby": {
"ask_to_join": "Pyydä liittymistä puheluun",
"join_as_guest": "Liity vieraana",
"join_button": "Liity puheluun",
"leave_button": "Takaisin viimeisimpiin puheluihin",
"waiting_for_invite": "Pyyntö lähetetty! Odotetaan lupaa liittyä…"
},
"log_in": "Kirjaudu sisään",
"logging_in": "Kirjaudutaan sisään…",
"login_auth_links": "<0>Luo tili</0> tai <2>Käytä vieraana</2>",
"login_auth_links_prompt": "Etkö ole vielä rekisteröitynyt?",
"login_subheading": "Jatkaaksesi Elementiin",
"login_title": "Kirjaudu sisään",
"microphone_off": "Mikrofoni pois päältä",
"microphone_on": "Mikrofoni päällä",
"mute_microphone_button_label": "Mykistä mikrofoni",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "QR-koodi",
"rageshake_button_error_caption": "Yritä uudelleen lokien lähettämistä",
"rageshake_request_modal": {
"body": "Toisella käyttäjällä tässä puhelussa on ongelma. Jotta voimme diagnosoida nämä ongelmat paremmin, haluaisimme kerätä virheenkorjauslokin.",
"title": "Virheenkorjauslokipyyntö"
},
"rageshake_send_logs": "Lähetä virheenkorjauslokit",
"rageshake_sending": "Lähetetään…",
"rageshake_sending_logs": "Lähetetään virheenkorjauslokeja…",
"rageshake_sent": "Kiitos!",
"recaptcha_dismissed": "Recaptcha hylätty",
"recaptcha_not_loaded": "Recaptcha ei ole ladattu",
"recaptcha_ssla_caption": "Tämä sivusto on suojattu ReCAPTCHA:lla, ja siihen sovelletaan Googlen <2>Tietosuojakäytäntöä</2> ja <6>Palveluehtoja</6>.<9></9>Klikkaamalla \"Rekisteröidy\" hyväksyt <12>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</12>",
"register": {
"passwords_must_match": "Salasanojen on täsmättävä",
"registering": "Rekisteröidään…"
},
"register_auth_links": "<0>Onko sinulla jo tili?</0><1><0>Kirjaudu sisään</0> tai <2>Käytä vieraana</2></1>",
"register_confirm_password_label": "Vahvista salasana",
"register_heading": "Luo tilisi",
"return_home_button": "Palaa aloitusnäyttöön",
"room_auth_view_continue_button": "Jatka",
"room_auth_view_ssla_caption": "Klikkaamalla \"Liity puheluun nyt\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
"screenshare_button_label": "Jaa näyttö",
"settings": {
"audio_tab": {
"effect_volume_description": "Säädä äänenvoimakkuutta, jolla reaktioiden ja käden nostojen ääniefektit toistetaan.",
"effect_volume_label": "Ääniefektien äänenvoimakkuus"
},
"background_blur_header": "Tausta",
"background_blur_label": "Sumenna videon tausta",
"blur_not_supported_by_browser": "(Tämä laite ei tue taustan sumennusta.)",
"developer_tab_title": "Kehittäjä",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Oletus",
"default_named": "Oletus <2>({{name}})</2>",
"microphone": "Mikrofoni",
"microphone_numbered": "Mikrofoni {{n}}",
"speaker": "Kaiutin",
"speaker_numbered": "Kaiutin {{n}}"
},
"feedback_tab_body": "Jos sinulla on ongelmia tai haluat vain antaa palautetta, lähetä meille lyhyt kuvaus alla.",
"feedback_tab_description_label": "Palautteesi",
"feedback_tab_h4": "Lähetä palautetta",
"feedback_tab_send_logs_label": "Sisällytä virheenkorjauslokit",
"feedback_tab_thank_you": "Kiitos, saimme palautteesi!",
"feedback_tab_title": "Palaute",
"opt_in_description": "<0></0><1></1>Voit peruuttaa suostumuksesi poistamalla tämän ruudun valinnan. Jos puhelu on käynnissä, tämä asetus tulee voimaan puhelun lopussa.",
"preferences_tab": {
"developer_mode_label": "Kehittäjätila",
"developer_mode_label_description": "Ota kehittäjätila käyttöön ja näytä kehittäjäasetukset-välilehti.",
"introduction": "Täällä voit määrittää lisävaihtoehtoja parempaa käyttökokemusta varten.",
"reactions_play_sound_description": "Toista äänitefekti, kun joku lähettää reaktion puheluun.",
"reactions_play_sound_label": "Toista reaktioäänet",
"reactions_show_description": "Näytä animaatio, kun joku lähettää reaktion.",
"reactions_show_label": "Näytä reaktiot",
"show_hand_raised_timer_description": "Näytä ajastin, kun osallistuja nostaa kätensä",
"show_hand_raised_timer_label": "Näytä kädennoston kesto"
}
},
"star_rating_input_label_one": "{{count}} tähti",
"star_rating_input_label_other": "{{count}} tähteä",
"start_new_call": "Aloita uusi puhelu",
"start_video_button_label": "Aloita video",
"stop_screenshare_button_label": "Jaetaan näyttöä",
"stop_video_button_label": "Lopeta video",
"submitting": "Lähetetään…",
"switch_camera": "Vaihda kameraa",
"unauthenticated_view_body": "Etkö ole vielä rekisteröitynyt? <2>Luo tili</2>",
"unauthenticated_view_login_button": "Kirjaudu tilillesi",
"unauthenticated_view_ssla_caption": "Klikkaamalla \"Siirry\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
"unmute_microphone_button_label": "Poista mikrofonin mykistys",
"version": "{{productName}} versio: {{version}}",
"video_tile": {
"always_show": "Näytä aina",
"camera_starting": "Videota ladataan...",
"change_fit_contain": "Sovita kehykseen",
"collapse": "Supista",
"expand": "Laajenna",
"mute_for_me": "Mykistä minulle",
"muted_for_me": "Mykistetty minulle",
"volume": "Äänenvoimakkuus",
"waiting_for_media": "Odotetaan mediaa..."
}
}

View File

@@ -5,14 +5,21 @@
"action": {
"close": "Tutup",
"copy_link": "Salin tautan",
"edit": "Sunting",
"go": "Bergabung",
"invite": "Undang",
"lower_hand": "Turunkan tangan",
"no": "Tidak",
"pick_reaction": "Pilih reaksi",
"raise_hand": "Angkat tangan",
"register": "Daftar",
"remove": "Hapus",
"show_less": "Tampilkan lebih sedikit",
"show_more": "Tampilkan lebih banyak",
"sign_in": "Masuk",
"sign_out": "Keluar",
"submit": "Kirim"
"submit": "Kirim",
"upload_file": "Unggah berkas"
},
"analytics_notice": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <5>Kebijakan Kuki</5> kami.",
"app_selection_modal": {
@@ -33,17 +40,69 @@
},
"call_name": "Nama panggilan",
"common": {
"analytics": "Analitik",
"audio": "Audio",
"avatar": "Avatar",
"back": "Kembali",
"display_name": "Nama tampilan",
"encrypted": "Terenkripsi",
"home": "Beranda",
"loading": "Memuat…",
"next": "Berikutnya",
"options": "Opsi",
"password": "Kata sandi",
"preferences": "Preferensi",
"profile": "Profil",
"reaction": "Reaksi",
"reactions": "Reaksi",
"settings": "Pengaturan",
"unencrypted": "Tidak terenkripsi",
"username": "Nama pengguna"
"username": "Nama pengguna",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Versi kripto: {{version}}",
"debug_tile_layout_label": "Awakutu tata letak ubin",
"device_id": "ID perangkat: {{id}}",
"duplicate_tiles_label": "Jumlah salinan ubin tambahan per peserta",
"environment_variables": "Variabel lingkungan",
"hostname": "Nama hos: {{hostname}}",
"livekit_server_info": "Info Server LiveKit",
"livekit_sfu": "SFU LiveKit: {{url}}",
"matrix_id": "ID Matrix: {{id}}",
"show_connection_stats": "Tampilkan statistik koneksi",
"show_non_member_tiles": "Tampilkan ubin untuk media non-anggota",
"url_params": "Parameter URL",
"use_new_membership_manager": "Gunakan implementasi baru dari panggilan MembershipManager",
"use_to_device_key_transport": "Gunakan untuk transportasi kunci perangkat. Ini akan kembali ke transportasi kunci ruangan ketika anggota panggilan lain mengirim kunci ruangan"
},
"disconnected_banner": "Koneksi ke server telah hilang.",
"error": {
"call_is_not_supported": "Panggilan tidak didukung",
"call_not_found": "Panggilan tidak ditemukan",
"call_not_found_description": "<0>Tautan itu tampaknya bukan milik panggilan yang ada. Periksa apakah Anda memiliki tautan yang tepat, atau <1> buat yang baru</1>.</0>",
"connection_lost": "Koneksi terputus",
"connection_lost_description": "Anda terputus dari panggilan.",
"e2ee_unsupported": "Peramban tidak kompatibel",
"e2ee_unsupported_description": "Peramban web Anda tidak mendukung panggilan terenkripsi. Peramban yang didukung meliputi Chrome, Safari, dan Firefox 117+.",
"generic": "Ada yang salah",
"generic_description": "Mengirimkan log awakutu akan membantu kami melacak masalah.",
"insufficient_capacity": "Kapasitas tidak mencukupi",
"insufficient_capacity_description": "Server telah mencapai kapasitas maksimum dan Anda tidak dapat bergabung dalam panggilan saat ini. Coba lagi nanti, atau hubungi admin server Anda jika masalah masih berlanjut.",
"matrix_rtc_focus_missing": "Server tidak dikonfigurasi untuk bekerja dengan {{brand}}. Silakan hubungi admin server Anda (Domain: {{domain}}, Kode Kesalahan: {{ errorCode }}).",
"open_elsewhere": "Dibuka di tab lain",
"open_elsewhere_description": "{{brand}} telah dibuka di tab lain. Jika sepertinya tidak benar, coba muat ulang halaman.",
"unexpected_ec_error": "Terjadi kesalahan tak terduga (<0> Kode Kesalahan:</0><1>{{ errorCode }}</1>). Silakan hubungi admin server Anda."
},
"group_call_loader": {
"banned_body": "Anda telah dilarang dari ruangan.",
"banned_heading": "Dilarang",
"call_ended_body": "Anda telah dikeluarkan dari panggilan.",
"call_ended_heading": "Panggilan berakhir",
"knock_reject_body": "Permintaan Anda untuk bergabung ditolak.",
"knock_reject_heading": "Akses ditolak",
"reason": "Alasan: {{reason}}"
},
"hangup_button_label": "Akhiri panggilan",
"header_label": "Beranda Element Call",
"header_participants_label": "Peserta",
@@ -59,15 +118,23 @@
"layout_grid_label": "Kisi",
"layout_spotlight_label": "Sorotan",
"lobby": {
"ask_to_join": "Permintaan untuk bergabung dalam panggilan",
"join_as_guest": "Bergabung sebagai tamu",
"join_button": "Bergabung ke panggilan",
"leave_button": "Kembali ke terkini"
"leave_button": "Kembali ke terkini",
"waiting_for_invite": "Permintaan terkirim! Menunggu izin untuk bergabung…"
},
"log_in": "Masuk",
"logging_in": "Memasuki…",
"login_auth_links": "<0>Buat akun</0> Atau <2>Akses sebagai tamu</2>",
"login_auth_links_prompt": "Belum terdaftar?",
"login_subheading": "Untuk melanjutkan ke Element",
"login_title": "Masuk",
"microphone_off": "Mikrofon dimatikan",
"microphone_on": "Mikrofon dinyalakan",
"mute_microphone_button_label": "Matikan mikrofon",
"participant_count_other": "{{count, number}}",
"qr_code": "Kode QR",
"rageshake_button_error_caption": "Kirim ulang catatan",
"rageshake_request_modal": {
"body": "Pengguna yang lain di panggilan ini sedang mengalami masalah. Supaya dapat mendiagnosa masalah ini, kami ingin mengumpulkan sebuah catatan pengawakutuan.",
@@ -79,23 +146,55 @@
"rageshake_sent": "Terima kasih!",
"recaptcha_dismissed": "Recaptcha ditutup",
"recaptcha_not_loaded": "Recaptcha tidak dimuat",
"recaptcha_ssla_caption": "Situs ini dilindungi oleh ReCAPTCHA dan <2>Kebijakan Privasi</2> dan <6>Ketentuan Layanan</6> Google berlaku.<9></9>Dengan mengeklik \"Daftar\", Anda menyetujui <12>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</12>",
"register": {
"passwords_must_match": "Kata sandi harus cocok",
"registering": "Mendaftarkan…"
},
"register_auth_links": "<0>Sudah punya akun?</0><1><0>Masuk</0> Atau <2>Akses sebagai tamu</2></1>",
"register_confirm_password_label": "Konfirmasi kata sandi",
"register_heading": "Buat akun Anda",
"return_home_button": "Kembali ke layar beranda",
"room_auth_view_continue_button": "Lanjutkan",
"room_auth_view_ssla_caption": "Dengan mengeklik “Gabung panggilan sekarang”, Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</2>",
"screenshare_button_label": "Bagikan layar",
"settings": {
"audio_tab": {
"effect_volume_description": "Sesuaikan volume saat reaksi dan efek tangan terangkat diputar.",
"effect_volume_label": "Volume efek suara"
},
"background_blur_header": "Latar belakang",
"background_blur_label": "Buramkan latar belakang video",
"blur_not_supported_by_browser": "(Pemburaman latar belakang tidak didukung oleh perangkat ini.)",
"developer_tab_title": "Pengembang",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Bawaan",
"default_named": "Bawaan <2>({{name}})</2>",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Speaker",
"speaker_numbered": "Speaker {{n}}"
},
"feedback_tab_body": "Jika Anda mengalami masalah atau hanya ingin memberikan masukan, silakan kirimkan kami deskripsi pendek di bawah.",
"feedback_tab_description_label": "Masukan Anda",
"feedback_tab_h4": "Kirim masukan",
"feedback_tab_send_logs_label": "Termasuk catatan pengawakutuan",
"feedback_tab_thank_you": "Terima kasih, kami telah menerima masukan Anda!",
"feedback_tab_title": "Masukan",
"opt_in_description": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan."
"opt_in_description": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan.",
"preferences_tab": {
"developer_mode_label": "Mode pengembang",
"developer_mode_label_description": "Aktifkan mode pengembang dan tampilkan tab pengaturan pengembang.",
"introduction": "Di sini Anda dapat mengonfigurasi opsi tambahan untuk pengalaman yang lebih baik.",
"reactions_play_sound_description": "Mainkan efek suara ketika seseorang mengirim reaksi ke panggilan.",
"reactions_play_sound_label": "Putar suara reaksi",
"reactions_show_description": "Tampilkan animasi ketika ada yang mengirimkan reaksi.",
"reactions_show_label": "Tampilkan reaksi",
"show_hand_raised_timer_description": "Tampilkan pengatur waktu saat peserta mengangkat tangannya",
"show_hand_raised_timer_label": "Tampilkan durasi angkat tangan"
}
},
"star_rating_input_label_one": "{{count}} bintang",
"star_rating_input_label_other": "{{count}} bintang",
@@ -104,8 +203,21 @@
"stop_screenshare_button_label": "Berbagi layar",
"stop_video_button_label": "Matikan video",
"submitting": "Mengirim…",
"switch_camera": "Ganti kamera",
"unauthenticated_view_body": "Belum terdaftar? <2>Buat sebuah akun</2>",
"unauthenticated_view_login_button": "Masuk ke akun Anda",
"unauthenticated_view_ssla_caption": "Dengan mengeklik \"Go\", Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</2>",
"unmute_microphone_button_label": "Nyalakan mikrofon",
"version": "Versi: {{version}}"
"version": "Versi: {{version}}",
"video_tile": {
"always_show": "Selalu tampilkan",
"camera_starting": "Memuat video...",
"change_fit_contain": "Sesuai dengan bingkai",
"collapse": "Tutup",
"expand": "Buka",
"mute_for_me": "Bisukan untuk saya",
"muted_for_me": "Dibisukan untuk saya",
"volume": "Volume",
"waiting_for_media": "Menunggu media..."
}
}

View File

@@ -4,15 +4,30 @@
},
"action": {
"close": "Aizvērt",
"copy_link": "Kopēt saiti",
"edit": "Labot",
"go": "Aiziet",
"invite": "Uzaicināt",
"lower_hand": "Nolaist roku",
"no": "Nē",
"pick_reaction": "Reaģēt",
"raise_hand": "Pacelt roku",
"register": "Reģistrēties",
"remove": "Noņemt",
"show_less": "Rādīt mazāk",
"show_more": "Rādīt vairāk",
"sign_in": "Pieteikties",
"sign_out": "Atteikties",
"submit": "Iesniegt"
"submit": "Iesniegt",
"upload_file": "Augšupielādēt failu"
},
"analytics_notice": "Piedaloties šajā beta versijā, jūs piekrītat anonīmu datu vākšanai, ko mēs izmantojam produkta uzlabošanai. Plašāku informāciju par to, kādus datus mēs izsekojam, varat atrast mūsu <2>konfidencialitātes politikā</2> un mūsu <6>sīkfailu politikā</6>.",
"app_selection_modal": {
"continue_in_browser": "Turpināt pārlūkprogrammā",
"open_in_app": "Atvērt lietotnē",
"text": "Gatavs pievienoties?",
"title": "Izvēlies lietotni"
},
"analytics_notice": "Piedalīšanās šajā beta apliecina piekrišanu anonīmu datu ievākšanai, ko mēs izmantojam, lai uzlabotu izstrādājumu. Vairāk informācijas par datiem, ko mēs ievācam, var atrast mūsu <2>privātuma nosacījumos</2> un <5>sīkdatņu nosacījumos</5>.",
"call_ended_view": {
"create_account_button": "Izveidot kontu",
"create_account_prompt": "<0>Kādēļ nepabeigt ar paroles iestatīšanu, lai paturētu savu kontu?</0><1>Būs iespējams paturēt savu vārdu un iestatīt attēlu izmantošanai turpmākajos zvanos</1>",
@@ -23,31 +38,104 @@
"reconnect_button": "Atkārtoti savienoties",
"survey_prompt": "Kā Tev veicās?"
},
"call_name": "Zvana nosaukums",
"common": {
"analytics": "Analītika",
"audio": "Skaņa",
"avatar": "Attēls",
"back": "Atpakaļ",
"display_name": "Attēlojamais vārds",
"encrypted": "Šifrēts",
"home": "Sākums",
"loading": "Lādējas…",
"next": "Nākamais",
"options": "Opcijas",
"password": "Parole",
"preferences": "Iestatījumi",
"profile": "Profils",
"reaction": "Reakcija",
"reactions": "Reakcijas",
"settings": "Iestatījumi",
"username": "Lietotājvārds"
"unencrypted": "Nav šifrēts",
"username": "Lietotājvārds",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Crypto versija: {{version}}",
"debug_tile_layout_label": "Vietu izkārtojuma atkļūdošana",
"device_id": "Ierīces ID: {{id}}",
"duplicate_tiles_label": "Papildu vietu kopiju skaits vienam dalībniekam",
"environment_variables": "Vides mainīgie",
"hostname": "Saimniekdatora nosaukums: {{hostname}}",
"livekit_server_info": "LiveKit Server informācija",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"show_connection_stats": "Rādīt savienojuma statistiku",
"show_non_member_tiles": "Rādīt vietu medijiem no ne-dalībniekiem",
"url_params": "URL parametri",
"use_new_membership_manager": "Izmantojiet jauno zvana MembershipManager versiju"
},
"disconnected_banner": "Ir zaudēts savienojums ar serveri.",
"error": {
"call_is_not_supported": "Zvans netiek atbalstīts",
"call_not_found": "Zvans nav atrasts",
"call_not_found_description": "<0>Šķiet, ka šī saite nepieder nevienam esošam zvaniem. Pārbaudiet, vai jums ir pareizā saite, vai <1> izveidojiet jaunu</1>. </0>",
"connection_lost": "Savienojums zaudēts",
"connection_lost_description": "Jūs tikāt atvienots no zvana.",
"e2ee_unsupported": "Nesaderīgs pārlūks",
"e2ee_unsupported_description": "Jūsu tīmekļa pārlūkprogramma neatbalsta encrypted zvanus. Atbalstītās pārlūkprogrammas ir Chrome, Safari un Firefox 117+.",
"generic": "Kaut kas nogāja greizi",
"generic_description": "Atkļūdošanas žurnālu iesniegšana palīdzēs mums izsekot problēmu.",
"insufficient_capacity": "Nepietiekama jauda",
"insufficient_capacity_description": "Serveris ir sasniedzis maksimālo ietilpību, un jūs šobrīd nevarat pievienoties zvanam. Mēģiniet vēlreiz vēlāk vai sazinieties ar servera administratoru, ja problēma joprojām pastāv.",
"matrix_rtc_focus_missing": "Serveris nav konfigurēts darbam ar{{brand}}. Lūdzu, sazinieties ar sava servera administratoru (Domēns: {{domain}}, Kļūdas kods: {{ errorCode }}).",
"open_elsewhere": "Atvērts citā cilnē",
"open_elsewhere_description": "{{brand}} ir atvērts citā cilnē. Ja tas neizklausās pareizi, mēģiniet atkārtoti ielādēt lapu.",
"unexpected_ec_error": "Negaidīta kļūda (<0>kļūdas kods: </0> <1> {{ errorCode }}</1>). Lūdzu, sazinieties ar servera administratoru."
},
"group_call_loader": {
"banned_body": "Jums ir liegta ieeja šajā istabā.",
"banned_heading": "Aizliegts",
"call_ended_body": "Jūs esat noņemts no zvana.",
"call_ended_heading": "Zvans beidzies",
"knock_reject_body": "Jūsu pieteikums pievienoties tika noraidīts.",
"knock_reject_heading": "Piekļuve liegta",
"reason": "Iemesls: {{reason}}"
},
"hangup_button_label": "Beigt zvanu",
"header_label": "Element Call sākums",
"header_participants_label": "Dalībnieki",
"invite_modal": {
"link_copied_toast": "Saite nokopēta",
"title": "Uzaicināt uz šo zvanu"
},
"join_existing_call_modal": {
"join_button": "Jā, pievienoties zvanam",
"text": "Šis zvans jau pastāv. Vai vēlies pievienoties?",
"title": "Pievienoties esošam zvanam?"
},
"layout_grid_label": "Režģis",
"layout_spotlight_label": "Starmešu gaisma",
"lobby": {
"join_button": "Pievienoties zvanam"
"ask_to_join": "Pieprasīt pievienoties zvanam",
"join_as_guest": "Pievienojies kā viesis",
"join_button": "Pievienoties zvanam",
"leave_button": "Atpakaļ uz jaunākajiem",
"waiting_for_invite": "Pieprasījums nosūtīts! Gaida atļauju pievienoties..."
},
"log_in": "Pieslēgties",
"logging_in": "Piesakās…",
"login_auth_links": "<0>Izveidot kontu</0> vai <2>Piekļūt kā viesim</2>",
"login_auth_links_prompt": "Vēl neesi reģistrējies?",
"login_subheading": "Lai turpinātu uz Element",
"login_title": "Pieteikties",
"microphone_off": "Mikrofons izslēgts",
"microphone_on": "Mikrofons ieslēgts",
"mute_microphone_button_label": "Izslēgt mikrofonu",
"participant_count_zero": "{{count, number}}",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "QR kods",
"rageshake_button_error_caption": "Atkārtoti mēģināt žurnāla ierakstu nosūtīšanu",
"rageshake_request_modal": {
"body": "Citam lietotājam šajā zvanā ir sarežģījumi. Lai labāk atklātu šīs nepilnības, mēs gribētu iegūt atkļūdošanas žurnālu.",
@@ -59,28 +147,76 @@
"rageshake_sent": "Paldies!",
"recaptcha_dismissed": "ReCaptcha atmesta",
"recaptcha_not_loaded": "ReCaptcha nav ielādēta",
"recaptcha_ssla_caption": "Šo vietni aizsargā ReCAPTCHA un tiek piemēroti Google <2>konfidencialitātes politika</2> un <6>Pakalpojuma noteikumi</6>.<9></9>Noklikšķinot uz \"Reģistrēties\", jūs piekrītat mūsu <12>Programmatūras un pakalpojumu licences līgumam (SSLA)</12>",
"register": {
"passwords_must_match": "Parolēm ir jāsakrīt",
"registering": "Reģistrē…"
},
"register_auth_links": "<0>Jau ir konts?</0><1><0>Pieteikties</0> vai <2>Piekļūt kā viesim</2></1>",
"register_confirm_password_label": "Apstiprināt paroli",
"register_heading": "Izveido kontu",
"return_home_button": "Atgriezties sākuma ekrānā",
"room_auth_view_continue_button": "Turpināt",
"room_auth_view_ssla_caption": "Noklikšķinot uz “Pievienoties zvanam tūlīt”, jūs piekrītat mūsu <2> programmatūras un pakalpojumu licences līgumam (SSLA) </2>",
"screenshare_button_label": "Kopīgot ekrānu",
"settings": {
"audio_tab": {
"effect_volume_description": "Pielāgojiet skaļumu, kurā tiek atskaņotas reakcijas un paceltas rokas skaņas.",
"effect_volume_label": "Skaņas efektu skaļums"
},
"developer_tab_title": "Izstrādātājs",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Noklusējums",
"default_named": "Noklusējums <2> ({{name}} )</2>",
"microphone": "Mikrofons",
"microphone_numbered": "Mikrofons {{n}}",
"speaker": "Skaļrunis",
"speaker_numbered": "Skaļrunis {{n}}"
},
"feedback_tab_body": "Ja tiek piedzīvoti sarežģījumi vai vienkārši ir vēlme sniegt kādu atsauksmi, lūgums zemāk nosūtīt mums īsu aprakstu.",
"feedback_tab_description_label": "Tava atsauksme",
"feedback_tab_h4": "Iesniegt atsauksmi",
"feedback_tab_send_logs_label": "Iekļaut atkļūdošanas žurnāla ierakstus",
"feedback_tab_thank_you": "Paldies, mēs saņēmām atsauksmi!",
"feedback_tab_title": "Atsauksmes",
"opt_in_description": "<0></0><1></1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās."
"opt_in_description": "<0></0><1></1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās.",
"preferences_tab": {
"developer_mode_label": "Izstrādātāja režīms",
"developer_mode_label_description": "Iespējot izstrādātāja režīmu un rādīt cilni izstrādātāja iestatījumi.",
"introduction": "Šeit varat konfigurēt papildu opcijas, lai uzlabotu pieredzi.",
"reactions_play_sound_description": "Atskaņojiet skaņas efektu, kad kāds sūta reakciju uz zvanu.",
"reactions_play_sound_label": "Atskaņojiet reakcijas skaņas",
"reactions_show_description": "Rādīt animāciju, kad kāds nosūta reakciju.",
"reactions_show_label": "Rādīt reakcijas",
"show_hand_raised_timer_description": "Rādīt taimeri, kad dalībnieks paceļ roku",
"show_hand_raised_timer_label": "Rādīt rokas pacelšanas ilgumu"
}
},
"star_rating_input_label_zero": "{{count}} zvaigznes",
"star_rating_input_label_one": "{{count}} zvaigzne",
"star_rating_input_label_other": "{{count}} zvaigznes",
"start_new_call": "Sākt jaunu zvanu",
"start_video_button_label": "Sākt video",
"stop_screenshare_button_label": "Kopīgo ekrānu",
"stop_video_button_label": "Apturēt video",
"submitting": "Iesniedz…",
"switch_camera": "Pārslēgt kameru",
"unauthenticated_view_body": "Vēl neesi reģistrējies? <2>Izveidot kontu</2>",
"unauthenticated_view_login_button": "Pieteikties kontā",
"version": "Versija: {{version}}"
"unauthenticated_view_ssla_caption": "Noklikšķinot uz \"Aiziet\", jūs piekrītat mūsu <2>Programmatūras un pakalpojumu licences līgumam (SSLA)</2>",
"unmute_microphone_button_label": "Ieslēgt mikrofonu",
"version": "{{productName}} versija: {{version}}",
"video_tile": {
"always_show": "Vienmēr rādīt",
"camera_starting": "Video ielāde...",
"change_fit_contain": "Pielāgot rāmim",
"collapse": "Sakļaut",
"expand": "Izvērst",
"mute_for_me": "Klusums man",
"muted_for_me": "Man izslēgts",
"volume": "Skaļums",
"waiting_for_media": "Gaida medijus..."
}
}

View File

@@ -4,20 +4,20 @@
},
"action": {
"close": "Închide",
"copy_link": "Copia linkul",
"copy_link": "Copiaţi linkul",
"edit": "Editare",
"go": "Du-te",
"invite": "Invită",
"lower_hand": "Mâna inferioară",
"no": "No",
"no": "Nu",
"pick_reaction": "Alegeți reacția",
"raise_hand": "Ridicați mâna",
"register": "Inregistrare",
"register": "Creaţi un cont",
"remove": "elimina",
"show_less": "Arată mai puțin",
"show_more": "Arată mai mult",
"sign_in": "Autentificare",
"sign_out": "Sign out",
"sign_out": "Deconecta-ţi-vă",
"submit": "Trimiteți",
"upload_file": "Încărcați fișierul"
},
@@ -26,27 +26,27 @@
"continue_in_browser": "Continuați în browser",
"open_in_app": "Deschideți în aplicație",
"text": "Sunteți gata să vă alăturați?",
"title": "Selectați aplicația"
"title": "Selectați o aplicație"
},
"call_ended_view": {
"create_account_button": "Creează cont",
"create_account_button": "Creaţi un cont",
"create_account_prompt": "<0>De ce să nu terminați prin configurarea unei parole pentru a vă păstra contul? </0><1>Veți putea să vă păstrați numele și să setați un avatar pentru a fi utilizat la apelurile viitoare </1>",
"feedback_done": "<0>Vă mulțumim pentru feedback! </0>",
"feedback_prompt": "<0>Ne-ar plăcea să auzim feedback-ul dvs., astfel încât să vă putem îmbunătăți experiența. </0>",
"headline": "{{displayName}}, apelul tău s-a încheiat.",
"not_now_button": "Nu acum, reveniți la ecranul de pornire",
"reconnect_button": "Reconecta",
"survey_prompt": "Cum a mers?"
"not_now_button": "Nu acum, reveniți la ecranul principal",
"reconnect_button": "Reconectaţi-vă",
"survey_prompt": "Cum a fost?"
},
"call_name": "Numele apelului",
"common": {
"analytics": "Analiză",
"audio": "Audio",
"avatar": "avatar",
"avatar": "Imaginea de profil",
"back": "Înapoi",
"display_name": "Nume afișat",
"encrypted": "Criptat",
"home": "Acasa",
"home": "Acasă",
"loading": "Se încarcă...",
"next": "Urmator\n",
"options": "Opțiuni",
@@ -55,19 +55,43 @@
"profile": "Profil",
"reaction": "Reacție",
"reactions": "Reacții",
"settings": "Settings",
"settings": "Setări",
"unencrypted": "Nu este criptat",
"username": "Nume utilizator",
"video": "Videoclip"
"username": "Numele utilizatorului",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Versiunea Crypto: {{version}}",
"debug_tile_layout_label": "Depanaţi aranjamentul cartonaşelor",
"device_id": "ID-ul dispozitivului: {{id}}",
"duplicate_tiles_label": "Numărul de exemplare suplimentare de cartonașe per participant",
"environment_variables": "Variabile de mediu",
"hostname": "Numele gazdei: {{hostname}}",
"matrix_id": "ID-ul matricei: {{id}}"
"matrix_id": "ID-ul matricei: {{id}}",
"show_connection_stats": "Afişaţi informaţii cu privire la starea conexiunii",
"show_non_member_tiles": "Afişaţi pictograme pentru fluxul media care nu aparţine participanţilor apelului",
"url_params": "Parametrii linkului",
"use_new_membership_manager": "Folosiţi noua versiune de administrator pentru participanţi ai apelului",
"use_to_device_key_transport": "Folosiţi metoda de transport direct către dispozitiv. Aceasta va reveni la transportul prin intermediul evenimentelor din cameră doar dacă un alt participant la apel recurge la acel mod de transport mai întâi."
},
"disconnected_banner": "Conexiunea către server s-a încheiat abrupt",
"error": {
"call_is_not_supported": "Acest tip de apel nu este suportat",
"call_not_found": "Apelul nu a fost găsit",
"call_not_found_description": "<0>Acel link nu pare să aparţină unui apel existent. Verificaţi dacă aţi introdus linkul corect, sau <1>creaţi unul nou</1>.</0>",
"connection_lost": "Conexiunea s-a pierdut",
"connection_lost_description": "Aţi fost deconectat/ă de la apel",
"e2ee_unsupported": "Navigatorul dumneavoastră este incompatibil cu criptarea integrală",
"e2ee_unsupported_description": "Navigatorul/browserul dumneavoastră web nu suportă apeluri în conversaţii cu criptare integrală. Printre navigatoarele suportate, se numără Chrome, Safari şi Firefox 117+.",
"generic": "A apărut o eroare neaşteptată",
"generic_description": "Dacă ne trimiteţi jurnalele de depanare generate de aplicaţie, ne puteţi ajuta să rezolvăm problema.",
"insufficient_capacity": "Capacitate insuficientă",
"insufficient_capacity_description": "Serverul a ajuns la capacitatea maximă și nu vă puteți alătura apelului în acest moment. Încercați din nou in câteva minute, sau contactați administratorul serverului dumneavoastră dacă problema persistă.",
"matrix_rtc_focus_missing": "Serverul nu este configurat să funcționeze cu{{brand}}. Vă rugăm să contactați administratorul serverului dumneavoastră pentru a raporta o eroare în configurare. Detalii: Domeniu: {{domain}}. Cod de eroare: {{ errorCode }}.",
"open_elsewhere": "Aplicaţia este deschisă intr-o altă pagină",
"open_elsewhere_description": "{{brand}} a fost deschis într-o altă pagină. Dacă credeți că acest mesaj a fost emis in eroare, încercați să reîncărcați pagina.",
"unexpected_ec_error": "A apărut o eroare neașteptată (Cod de <0> eroare: </0> <1> {{ errorCode }}</1>). Vă rugăm să contactați administratorul serverului dumneavoastră."
},
"disconnected_banner": "Conectivitatea la server a fost pierdută.",
"group_call_loader": {
"banned_body": "Ai fost interzis să ieși din cameră.",
"banned_heading": "Interzis",
@@ -135,6 +159,10 @@
"effect_volume_label": "Volumul efectului sonor"
},
"developer_tab_title": "dezvoltator",
"devices": {
"microphone": "Microfon",
"speaker": "Difuzor"
},
"feedback_tab_body": "Dacă întâmpinați probleme sau pur și simplu doriți să oferiți feedback, vă rugăm să ne trimiteți o scurtă descriere mai jos.",
"feedback_tab_description_label": "Feedback-ul tău",
"feedback_tab_h4": "Trimiteți Feedback",
@@ -143,6 +171,8 @@
"feedback_tab_title": "Feedback",
"opt_in_description": "<0></0><1></1>Puteți retrage consimțământul debifând această casetă. Dacă sunteți în prezent la un apel, această setare va intra în vigoare la sfârșitul apelului.",
"preferences_tab": {
"developer_mode_label": "Modul dezvoltator",
"developer_mode_label_description": "Activați modul dezvoltator și afișați pagina specifică setărilor pentru dezvoltatori",
"introduction": "Aici puteți configura opțiuni suplimentare pentru o experiență îmbunătățită.",
"reactions_play_sound_description": "Redați un efect sonor atunci când cineva trimite o reacție la un apel.",
"reactions_play_sound_label": "Redați sunete de reacție",
@@ -164,11 +194,13 @@
"version": "{{productName}}Versiune: {{version}}",
"video_tile": {
"always_show": "Arată întotdeauna",
"camera_starting": "Se încarcă fluxul video...",
"change_fit_contain": "Se potrivește cadrului",
"collapse": "colaps",
"expand": "Extindeți",
"mute_for_me": "Mute pentru mine",
"muted_for_me": "Dezactivat pentru mine",
"volume": "VOLUM"
"volume": "VOLUM",
"waiting_for_media": "Flux multimedia în aşteptare"
}
}

View File

@@ -21,7 +21,7 @@
"submit": "Отправить",
"upload_file": "Загрузить файл"
},
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности</2> и нашей <5> Политике использования файлов cookie</5>.",
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Дополнительную информацию о том, какие данные мы отслеживаем, можно найти в нашей <2> Политике конфиденциальности </2> и Политике <6> использования файлов cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Продолжить в браузере",
"open_in_app": "Открыть в приложении",
@@ -65,13 +65,16 @@
"debug_tile_layout_label": "Отладка расположения плиток",
"device_id": "Идентификатор устройства: {{id}}",
"duplicate_tiles_label": "Количество дополнительных копий плиток на участника",
"environment_variables": "Переменные окружения",
"hostname": "Имя хоста: {{hostname}}",
"livekit_server_info": "Информация о сервере LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"show_connection_stats": "Показать статистику подключений",
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
"use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager"
"url_params": "Параметры URL-адреса",
"use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager",
"use_to_device_key_transport": "Используйте для передачи ключей устройства. Это позволит вернуться к передаче ключей комнаты, когда другой участник вызова отправит ключ комнаты"
},
"disconnected_banner": "Связь с сервером была потеряна.",
"error": {
@@ -98,7 +101,7 @@
"call_ended_heading": "Вызов завершен",
"knock_reject_body": "Участники комнаты отклонили ваш запрос на присоединение.",
"knock_reject_heading": "Не разрешено присоединиться",
"reason": "Причина"
"reason": "Причина: {{reason}}"
},
"hangup_button_label": "Завершить звонок",
"header_label": "Главная Element Call",
@@ -145,6 +148,7 @@
"rageshake_sent": "Спасибо!",
"recaptcha_dismissed": "Проверка не пройдена",
"recaptcha_not_loaded": "Невозможно начать проверку",
"recaptcha_ssla_caption": "Этот сайт защищен reCAPTCHA, и к нему применяются <2> Политика конфиденциальности </2> и <6> Условия обслуживания Google. </6> <9></9>Нажимая «Зарегистрироваться», вы соглашаетесь с нашим лицензионным соглашением на <12> программное обеспечение и услуги (SSLA) </12>",
"register": {
"passwords_must_match": "Пароли должны совпадать",
"registering": "Регистрация…"
@@ -154,12 +158,16 @@
"register_heading": "Создать учетную запись",
"return_home_button": "Вернуться в Начало",
"room_auth_view_continue_button": "Продолжить",
"room_auth_view_ssla_caption": "Нажимая кнопку \"Присоединиться к звонку\", вы соглашаетесь с нашим <2>Лицензионное соглашение на программное обеспечение и услуги (SSLA)</2>",
"screenshare_button_label": "Поделиться экраном",
"settings": {
"audio_tab": {
"effect_volume_description": "Отрегулируйте громкость воспроизведения реакций и эффектов поднятия руки.",
"effect_volume_label": "Громкость звукового эффекта"
},
"background_blur_header": "Фон",
"background_blur_label": "Размытие фона видео",
"blur_not_supported_by_browser": "(Размытие фона не поддерживается этим устройством.)",
"developer_tab_title": "Разработчику",
"devices": {
"camera": "Камера",
@@ -190,8 +198,9 @@
"show_hand_raised_timer_label": "Показать продолжительность поднятия руки"
}
},
"star_rating_input_label_one": "{{count}} отмечен",
"star_rating_input_label_other": "{{count}} отмеченных",
"star_rating_input_label_one": "{{count}} звезда",
"star_rating_input_label_few": "{{count}} звезды",
"star_rating_input_label_many": "{{count}} звезд",
"start_new_call": "Начать новый звонок",
"start_video_button_label": "Начать видео",
"stop_screenshare_button_label": "Демонстрация экрана",
@@ -200,8 +209,9 @@
"switch_camera": "Переключить камеру",
"unauthenticated_view_body": "Ещё не зарегистрированы? <2>Создайте аккаунт</2>",
"unauthenticated_view_login_button": "Войдите в свой аккаунт",
"unauthenticated_view_ssla_caption": "Нажимая «Перейти», вы соглашаетесь с нашим лицензионным соглашением на <2> программное обеспечение и услуги (SSLA) </2>",
"unmute_microphone_button_label": "Включить микрофон",
"version": "Версия: {{version}}",
"version": "{{productName}} версия: {{version}}",
"video_tile": {
"always_show": "Показывать всегда",
"camera_starting": "Загрузка видео...",

View File

@@ -21,7 +21,7 @@
"submit": "Odoslať",
"upload_file": "Nahrať súbor"
},
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <5>Zásadách používania súborov cookie</5>.",
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <6>Zásadách používania súborov cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Pokračovať v prehliadači",
"open_in_app": "Otvoriť v aplikácii",
@@ -65,13 +65,17 @@
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
"device_id": "ID zariadenia: {{id}}",
"duplicate_tiles_label": "Počet ďalších kópií dlaždíc na účastníka",
"environment_variables": "Premenné prostredia",
"hostname": "Názov hostiteľa: {{hostname}}",
"livekit_server_info": "Informácie o serveri LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Stlmiť všetky zvuky (účastníkov, reakcií, zvuky pripojenia)",
"show_connection_stats": "Zobraziť štatistiky pripojenia",
"show_non_member_tiles": "Zobraziť dlaždice pre nečlenské médiá",
"use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager"
"url_params": "Parametre URL adresy",
"use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager",
"use_to_device_key_transport": "Používa sa na prenos kľúča zariadenia. Toto sa vráti k prenosu kľúča miestnosti, keď iný účastník hovoru poslal kľúč od miestnosti"
},
"disconnected_banner": "Spojenie so serverom sa stratilo.",
"error": {
@@ -145,6 +149,7 @@
"rageshake_sent": "Ďakujeme!",
"recaptcha_dismissed": "Recaptcha zamietnutá",
"recaptcha_not_loaded": "Recaptcha sa nenačítala",
"recaptcha_ssla_caption": "Táto stránka je chránená reCAPTCHA a platia <2>Zásady ochrany osobných údajov</2> a <6>Podmienky služby</6> spoločnosti Google. <9></9>Kliknutím na tlačidlo „Registrovať“ súhlasíte s našou <12>Licenčnou zmluvou o softvéri a službách (SSLA)</12>",
"register": {
"passwords_must_match": "Heslá sa musia zhodovať",
"registering": "Registrácia…"
@@ -154,12 +159,16 @@
"register_heading": "Vytvorte si účet",
"return_home_button": "Návrat na domovskú obrazovku",
"room_auth_view_continue_button": "Pokračovať",
"room_auth_view_ssla_caption": "Kliknutím na „Pripojiť sa k hovoru teraz“ súhlasíte s našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)</2>",
"screenshare_button_label": "Zdieľať obrazovku",
"settings": {
"audio_tab": {
"effect_volume_description": "Upraviť hlasitosť, pri ktorej sa prehrávajú reakcie a efekty zdvihnutia ruky.",
"effect_volume_label": "Hlasitosť zvukového efektu"
},
"background_blur_header": "Pozadie",
"background_blur_label": "Rozmazať pozadie videa",
"blur_not_supported_by_browser": "(Rozmazanie pozadia nie je podporované týmto zariadením.)",
"developer_tab_title": "Vývojár",
"devices": {
"camera": "Kamera",
@@ -191,6 +200,7 @@
}
},
"star_rating_input_label_one": "{{count}} hviezdička",
"star_rating_input_label_few": "{{count}} hviezdičky",
"star_rating_input_label_other": "{{count}} hviezdičiek",
"start_new_call": "Spustiť nový hovor",
"start_video_button_label": "Spustiť video",
@@ -200,8 +210,9 @@
"switch_camera": "Prepnúť fotoaparát",
"unauthenticated_view_body": "Ešte nie ste zaregistrovaný? <2>Vytvorte si účet</2>",
"unauthenticated_view_login_button": "Prihláste sa do svojho konta",
"unauthenticated_view_ssla_caption": "Kliknutím na tlačidlo „Prejsť“ súhlasítes našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)</2>",
"unmute_microphone_button_label": "Zrušiť stlmenie mikrofónu",
"version": "Verzia: {{version}}",
"version": "Verzia {{productName}}: {{version}}",
"video_tile": {
"always_show": "Vždy zobraziť",
"camera_starting": "Načítavanie videa...",

View File

@@ -73,7 +73,8 @@
"show_connection_stats": "Visa anslutningsstatistik",
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
"url_params": "URL-parametrar",
"use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager"
"use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager",
"use_to_device_key_transport": "Använd \"till enhet\"-nyckeltransport. Detta kommer att falla tillbaka till rumsnyckeltransport om en annan samtalsmedlem skickar en rumsnyckel."
},
"disconnected_banner": "Anslutningen till servern har brutits.",
"error": {

View File

@@ -21,7 +21,7 @@
"submit": "Надіслати",
"upload_file": "Завантажити файл"
},
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <5>Політиці про куки</5>.",
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <6>Політиці про файли cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Продовжити у браузері",
"open_in_app": "Відкрити у застосунку",
@@ -194,8 +194,9 @@
"show_hand_raised_timer_label": "Показувати тривалість підняття руки"
}
},
"star_rating_input_label_one": "{{count}} зірок",
"star_rating_input_label_other": "{{count}} зірок",
"star_rating_input_label_one": "{{count}} зірка",
"star_rating_input_label_few": "{{count}} зірки",
"star_rating_input_label_many": "{{count}} зірок",
"start_new_call": "Розпочати новий виклик",
"start_video_button_label": "Розпочати відео",
"stop_screenshare_button_label": "Презентація екрана",
@@ -206,7 +207,7 @@
"unauthenticated_view_login_button": "Увійдіть до свого облікового запису",
"unauthenticated_view_ssla_caption": "Натискаючи \"Перейти\", ви погоджуєтеся з нашою <2>Ліцензійною угодою на програмне забезпечення та послуги (SSLA) </2>",
"unmute_microphone_button_label": "Увімкнути мікрофон",
"version": "Версія: {{version}}",
"version": "{{productName}} версія: {{version}}",
"video_tile": {
"always_show": "Показувати завжди",
"camera_starting": "Завантаження відео...",

View File

@@ -4,7 +4,9 @@
},
"action": {
"close": "关闭",
"copy_link": "复制链接",
"go": "开始",
"invite": "邀请",
"no": "否",
"register": "注册",
"remove": "移除",
@@ -31,12 +33,14 @@
},
"call_name": "通话名称",
"common": {
"analytics": "分析",
"audio": "音频",
"avatar": "头像",
"display_name": "显示名称",
"encrypted": "已加密",
"home": "主页",
"loading": "加载中……",
"options": "选项",
"password": "密码",
"profile": "个人信息",
"settings": "设置",
@@ -45,8 +49,20 @@
"video": "视频"
},
"disconnected_banner": "与服务器的连接中断。",
"group_call_loader": {
"banned_body": "你已被房间封禁",
"banned_heading": "已被封禁",
"call_ended_body": "你已被移出通话",
"call_ended_heading": "通话结束",
"knock_reject_body": "房间成员拒绝了你的加入请求"
},
"hangup_button_label": "通话结束",
"header_label": "Element Call主页",
"header_participants_label": "参与者",
"invite_modal": {
"link_copied_toast": "链接已复制到剪贴板",
"title": "邀请参加此次通话"
},
"join_existing_call_modal": {
"join_button": "是,加入通话",
"text": "该通话已存在,你想加入吗?",
@@ -58,12 +74,16 @@
"join_button": "加入通话",
"leave_button": "返回最近通话"
},
"log_in": "登录",
"logging_in": "登录中……",
"login_auth_links": "<0>创建账户</0> Or <2>以访客身份继续</2>",
"login_auth_links_prompt": "还未注册?",
"login_subheading": "继续使用 Element",
"login_title": "登录",
"microphone_off": "麦克风关闭",
"microphone_on": "麦克风开启",
"mute_microphone_button_label": "静音麦克风",
"participant_count_other": "{{count, number}}",
"rageshake_button_error_caption": "重传日志",
"rageshake_request_modal": {
"body": "这个通话中的另一个用户出现了问题。为了更好地诊断这些问题,我们想收集调试日志。",
@@ -81,6 +101,7 @@
},
"register_auth_links": "<0>已有账户?</0><1><0>登录</0> Or <2>以访客身份继续</2></1>",
"register_confirm_password_label": "确认密码",
"register_heading": "创建您的账户",
"return_home_button": "返回主页",
"screenshare_button_label": "屏幕共享",
"settings": {
@@ -103,5 +124,10 @@
"unauthenticated_view_body": "还没有注册? <2>创建账户<2>",
"unauthenticated_view_login_button": "登录你的账户",
"unmute_microphone_button_label": "取消麦克风静音",
"version": "版本:{{version}}"
"version": "版本:{{version}}",
"video_tile": {
"change_fit_contain": "贴合框架",
"mute_for_me": "为我静音",
"volume": "音量"
}
}

View File

@@ -39,7 +39,7 @@
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.33.0",
"@livekit/protocol": "^1.38.0",
"@livekit/track-processors": "^0.5.5",
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
@@ -62,6 +62,7 @@
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/content-type": "^1.1.5",
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12",
@@ -98,11 +99,11 @@
"i18next-parser": "^9.1.0",
"jsdom": "^26.0.0",
"knip": "^5.27.2",
"livekit-client": "^2.11.3",
"livekit-client": "^2.13.0",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#19b1b901f575755d29d1fe03ca48cbf7c1cae05c",
"matrix-widget-api": "1.11.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
"matrix-widget-api": "^1.13.0",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
@@ -132,8 +133,7 @@
},
"resolutions": {
"@livekit/components-core/rxjs": "^7.8.1",
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
"matrix-widget-api": "1.11.0"
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18"
},
"packageManager": "yarn@4.7.0"
}

View File

@@ -49,7 +49,7 @@ test("Sign up a new account, then login, then logout", async ({ browser }) => {
// logout
await returningUserPage.getByTestId("usermenu_open").click();
await returningUserPage.locator('[data-test-id="usermenu_logout"]').click();
await returningUserPage.locator('[data-testid="usermenu_logout"]').click();
await expect(
returningUserPage.getByRole("link", { name: "Log In" }),

View File

@@ -1,146 +0,0 @@
/* eslint-disable */
// The contents of this file below the line are copied from
// @types/dom-mediacapture-transform, which is inlined here into Element Call so
// that we can apply the patch to @types/dom-webcodecs found in
// ./dom-webcodecs.d.ts, which it depends on.
// (https://github.com/DefinitelyTyped/DefinitelyTyped/pull/72625)
// Once that PR is merged and released, we can remove this file and return to
// depending on @types/dom-mediacapture-transform.
// -----------------------------------------------------------------------------
// This project is licensed under the MIT license.
// Copyrights are respective of each contributor listed at the beginning of each definition file.
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// In general, these types are only available behind a command line flag or an origin trial in
// Chrome 90+.
// This API depends on WebCodecs.
// Versioning:
// Until the above-mentioned spec is finalized, the major version number is 0. Although not
// necessary for version 0, consider incrementing the minor version number for breaking changes.
// The following modify existing DOM types to allow defining type-safe APIs on audio and video tracks.
/** Specialize MediaStreamTrack so that we can refer specifically to an audio track. */
interface MediaStreamAudioTrack extends MediaStreamTrack {
readonly kind: "audio";
clone(): MediaStreamAudioTrack;
}
/** Specialize MediaStreamTrack so that we can refer specifically to a video track. */
interface MediaStreamVideoTrack extends MediaStreamTrack {
readonly kind: "video";
clone(): MediaStreamVideoTrack;
}
/** Assert that getAudioTracks and getVideoTracks return the tracks with the appropriate kind. */
interface MediaStream {
getAudioTracks(): MediaStreamAudioTrack[];
getVideoTracks(): MediaStreamVideoTrack[];
}
// The following were originally generated from the spec using
// https://github.com/microsoft/TypeScript-DOM-lib-generator, then heavily modified.
/**
* A track sink that is capable of exposing the unencoded frames from the track to a
* ReadableStream, and exposes a control channel for signals going in the oppposite direction.
*/
interface MediaStreamTrackProcessor<T extends AudioData | VideoFrame> {
/**
* Allows reading the frames flowing through the MediaStreamTrack provided to the constructor.
*/
readonly readable: ReadableStream<T>;
/** Allows sending control signals to the MediaStreamTrack provided to the constructor. */
readonly writableControl: WritableStream<MediaStreamTrackSignal>;
}
declare var MediaStreamTrackProcessor: {
prototype: MediaStreamTrackProcessor<any>;
/** Constructor overrides based on the type of track. */
new (
init: MediaStreamTrackProcessorInit & { track: MediaStreamAudioTrack },
): MediaStreamTrackProcessor<AudioData>;
new (
init: MediaStreamTrackProcessorInit & { track: MediaStreamVideoTrack },
): MediaStreamTrackProcessor<VideoFrame>;
};
interface MediaStreamTrackProcessorInit {
track: MediaStreamTrack;
/**
* If media frames are not read from MediaStreamTrackProcessor.readable quickly enough, the
* MediaStreamTrackProcessor will internally buffer up to maxBufferSize of the frames produced
* by the track. If the internal buffer is full, each time the track produces a new frame, the
* oldest frame in the buffer will be dropped and the new frame will be added to the buffer.
*/
maxBufferSize?: number | undefined;
}
/**
* Takes video frames as input, and emits control signals that result from subsequent processing.
*/
interface MediaStreamTrackGenerator<T extends AudioData | VideoFrame>
extends MediaStreamTrack {
/**
* Allows writing media frames to the MediaStreamTrackGenerator, which is itself a
* MediaStreamTrack. When a frame is written to writable, the frames close() method is
* automatically invoked, so that its internal resources are no longer accessible from
* JavaScript.
*/
readonly writable: WritableStream<T>;
/**
* Allows reading control signals sent from any sinks connected to the
* MediaStreamTrackGenerator.
*/
readonly readableControl: ReadableStream<MediaStreamTrackSignal>;
}
type MediaStreamAudioTrackGenerator = MediaStreamTrackGenerator<AudioData> &
MediaStreamAudioTrack;
type MediaStreamVideoTrackGenerator = MediaStreamTrackGenerator<VideoFrame> &
MediaStreamVideoTrack;
declare var MediaStreamTrackGenerator: {
prototype: MediaStreamTrackGenerator<any>;
/** Constructor overrides based on the type of track. */
new (
init: MediaStreamTrackGeneratorInit & {
kind: "audio";
signalTarget?: MediaStreamAudioTrack | undefined;
},
): MediaStreamAudioTrackGenerator;
new (
init: MediaStreamTrackGeneratorInit & {
kind: "video";
signalTarget?: MediaStreamVideoTrack | undefined;
},
): MediaStreamVideoTrackGenerator;
};
interface MediaStreamTrackGeneratorInit {
kind: MediaStreamTrackGeneratorKind;
/**
* (Optional) track to which the MediaStreamTrackGenerator will automatically forward control
* signals. If signalTarget is provided and signalTarget.kind and kind do not match, the
* MediaStreamTrackGenerators constructor will raise an exception.
*/
signalTarget?: MediaStreamTrack | undefined;
}
type MediaStreamTrackGeneratorKind = "audio" | "video";
type MediaStreamTrackSignalType = "request-frame";
interface MediaStreamTrackSignal {
signalType: MediaStreamTrackSignalType;
}

View File

@@ -1,745 +0,0 @@
/* eslint-disable */
// The contents of this file below the line are copied from
// @types/dom-webcodecs, which is inlined here into Element Call so that we can
// apply the patch https://github.com/DefinitelyTyped/DefinitelyTyped/pull/72625
// which is needed for TypeScript 5.8 compatibility. Once that PR is merged and
// released, we can remove this file and return to depending on
// @types/dom-webcodecs.
// -----------------------------------------------------------------------------
// This project is licensed under the MIT license.
// Copyrights are respective of each contributor listed at the beginning of each definition file.
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// Versioning:
// Until the WebCodecs spec is finalized, the major version number is 0. I have chosen to use minor
// version 1 to denote the API as defined by the IDL files from the Chromium repo at
// https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/webcodecs.
// Please use a version number above 0.1 if using the spec at https://w3c.github.io/webcodecs/ as
// the source.
// The declarations in webcodecs.generated.d.ts have been generated using the code in
// https://github.com/yume-chan/webcodecs-lib-generator. See
// https://github.com/yume-chan/webcodecs-lib-generator/blob/main/README.md for more detail.
// The following declarations are copied from
// https://github.com/microsoft/TypeScript-DOM-lib-generator/blob/a75338e1ea8a958bf08a5745141d2ab8f14ba2ca/baselines/dom.generated.d.ts
// and modified to expand the types to include VideoFrame.
/** Shim for OffscreenCanvas, which was removed in TS 4.4 */
interface OffscreenCanvas extends EventTarget {}
/**
* Replaces CanvasImageSource; only applies if WebCodecs is available.
*/
type CanvasImageSourceWebCodecs =
| HTMLOrSVGImageElement
| HTMLVideoElement
| HTMLCanvasElement
| ImageBitmap
| OffscreenCanvas
| VideoFrame;
interface CanvasRenderingContext2D {
drawImage(image: CanvasImageSourceWebCodecs, dx: number, dy: number): void;
drawImage(
image: CanvasImageSourceWebCodecs,
dx: number,
dy: number,
dw: number,
dh: number,
): void;
drawImage(
image: CanvasImageSourceWebCodecs,
sx: number,
sy: number,
sw: number,
sh: number,
dx: number,
dy: number,
dw: number,
dh: number,
): void;
createPattern(
image: CanvasImageSourceWebCodecs,
repetition: string | null,
): CanvasPattern | null;
}
interface OffscreenCanvasRenderingContext2D {
drawImage(image: CanvasImageSourceWebCodecs, dx: number, dy: number): void;
drawImage(
image: CanvasImageSourceWebCodecs,
dx: number,
dy: number,
dw: number,
dh: number,
): void;
drawImage(
image: CanvasImageSourceWebCodecs,
sx: number,
sy: number,
sw: number,
sh: number,
dx: number,
dy: number,
dw: number,
dh: number,
): void;
createPattern(
image: CanvasImageSourceWebCodecs,
repetition: string | null,
): CanvasPattern | null;
}
/**
* Replaces ImageBitmapSource; only applies if WebCodecs is available.
*/
type ImageBitmapSourceWebCodecs = CanvasImageSourceWebCodecs | Blob | ImageData;
declare function createImageBitmap(
image: ImageBitmapSourceWebCodecs,
options?: ImageBitmapOptions,
): Promise<ImageBitmap>;
declare function createImageBitmap(
image: ImageBitmapSourceWebCodecs,
sx: number,
sy: number,
sw: number,
sh: number,
options?: ImageBitmapOptions,
): Promise<ImageBitmap>;
/**
* Replaces TexImageSource; only applies if WebCodecs is available.
*/
type TexImageSourceWebCodecs =
| ImageBitmap
| ImageData
| HTMLImageElement
| HTMLCanvasElement
| HTMLVideoElement
| OffscreenCanvas
| VideoFrame;
interface WebGLRenderingContextOverloads {
texImage2D(
target: GLenum,
level: GLint,
internalformat: GLint,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
texSubImage2D(
target: GLenum,
level: GLint,
xoffset: GLint,
yoffset: GLint,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
}
interface WebGL2RenderingContextBase {
texImage3D(
target: GLenum,
level: GLint,
internalformat: GLint,
width: GLsizei,
height: GLsizei,
depth: GLsizei,
border: GLint,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
texSubImage3D(
target: GLenum,
level: GLint,
xoffset: GLint,
yoffset: GLint,
zoffset: GLint,
width: GLsizei,
height: GLsizei,
depth: GLsizei,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
}
interface WebGL2RenderingContextOverloads {
texImage2D(
target: GLenum,
level: GLint,
internalformat: GLint,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
texImage2D(
target: GLenum,
level: GLint,
internalformat: GLint,
width: GLsizei,
height: GLsizei,
border: GLint,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
texSubImage2D(
target: GLenum,
level: GLint,
xoffset: GLint,
yoffset: GLint,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
texSubImage2D(
target: GLenum,
level: GLint,
xoffset: GLint,
yoffset: GLint,
width: GLsizei,
height: GLsizei,
format: GLenum,
type: GLenum,
source: TexImageSourceWebCodecs,
): void;
}
/////////////////////////////
/// webcodecs APIs
/////////////////////////////
interface AudioDataCopyToOptions {
format?: AudioSampleFormat | undefined;
frameCount?: number | undefined;
frameOffset?: number | undefined;
planeIndex: number;
}
interface AudioDataInit {
data: AllowSharedBufferSource;
format: AudioSampleFormat;
numberOfChannels: number;
numberOfFrames: number;
sampleRate: number;
timestamp: number;
}
interface AudioDecoderConfig {
codec: string;
description?: AllowSharedBufferSource | undefined;
numberOfChannels: number;
sampleRate: number;
}
interface AudioDecoderInit {
error: WebCodecsErrorCallback;
output: AudioDataOutputCallback;
}
interface AudioDecoderSupport {
config?: AudioDecoderConfig;
supported?: boolean;
}
interface AudioEncoderConfig {
bitrate?: number | undefined;
codec: string;
numberOfChannels: number;
sampleRate: number;
}
interface AudioEncoderInit {
error: WebCodecsErrorCallback;
output: EncodedAudioChunkOutputCallback;
}
interface AudioEncoderSupport {
config?: AudioEncoderConfig;
supported?: boolean;
}
interface AvcEncoderConfig {
format?: AvcBitstreamFormat | undefined;
}
interface EncodedAudioChunkInit {
data: AllowSharedBufferSource;
duration?: number | undefined;
timestamp: number;
type: EncodedAudioChunkType;
}
interface EncodedAudioChunkMetadata {
decoderConfig?: AudioDecoderConfig | undefined;
}
interface EncodedVideoChunkInit {
data: AllowSharedBufferSource;
duration?: number | undefined;
timestamp: number;
type: EncodedVideoChunkType;
}
interface EncodedVideoChunkMetadata {
decoderConfig?: VideoDecoderConfig | undefined;
temporalLayerId?: number | undefined;
}
interface ImageDecodeOptions {
completeFramesOnly?: boolean | undefined;
frameIndex?: number | undefined;
}
interface ImageDecodeResult {
complete: boolean;
image: VideoFrame;
}
interface ImageDecoderInit {
colorSpaceConversion?: ColorSpaceConversion | undefined;
data: ImageBufferSource;
desiredHeight?: number | undefined;
desiredWidth?: number | undefined;
preferAnimation?: boolean | undefined;
premultiplyAlpha?: PremultiplyAlpha | undefined;
type: string;
}
interface PlaneLayout {
offset: number;
stride: number;
}
interface VideoColorSpaceInit {
fullRange?: boolean | null | undefined;
matrix?: VideoMatrixCoefficients | null | undefined;
primaries?: VideoColorPrimaries | null | undefined;
transfer?: VideoTransferCharacteristics | null | undefined;
}
interface VideoDecoderConfig {
codec: string;
codedHeight?: number | undefined;
codedWidth?: number | undefined;
colorSpace?: VideoColorSpaceInit | undefined;
description?: AllowSharedBufferSource | undefined;
displayAspectHeight?: number | undefined;
displayAspectWidth?: number | undefined;
hardwareAcceleration?: HardwarePreference | undefined;
optimizeForLatency?: boolean | undefined;
}
interface VideoDecoderInit {
error: WebCodecsErrorCallback;
output: VideoFrameOutputCallback;
}
interface VideoDecoderSupport {
config?: VideoDecoderConfig;
supported?: boolean;
}
interface VideoEncoderConfig {
alpha?: AlphaOption | undefined;
avc?: AvcEncoderConfig | undefined;
bitrate?: number | undefined;
bitrateMode?: VideoEncoderBitrateMode | undefined;
codec: string;
displayHeight?: number | undefined;
displayWidth?: number | undefined;
framerate?: number | undefined;
hardwareAcceleration?: HardwarePreference | undefined;
height: number;
latencyMode?: LatencyMode | undefined;
scalabilityMode?: string | undefined;
width: number;
}
interface VideoEncoderEncodeOptions {
keyFrame?: boolean;
}
interface VideoEncoderInit {
error: WebCodecsErrorCallback;
output: EncodedVideoChunkOutputCallback;
}
interface VideoEncoderSupport {
config?: VideoEncoderConfig;
supported?: boolean;
}
interface VideoFrameBufferInit {
codedHeight: number;
codedWidth: number;
colorSpace?: VideoColorSpaceInit | undefined;
displayHeight?: number | undefined;
displayWidth?: number | undefined;
duration?: number | undefined;
format: VideoPixelFormat;
layout?: PlaneLayout[] | undefined;
timestamp: number;
visibleRect?: DOMRectInit | undefined;
}
interface VideoFrameCopyToOptions {
layout?: PlaneLayout[] | undefined;
rect?: DOMRectInit | undefined;
}
interface VideoFrameInit {
alpha?: AlphaOption | undefined;
displayHeight?: number | undefined;
displayWidth?: number | undefined;
duration?: number | undefined;
timestamp?: number | undefined;
visibleRect?: DOMRectInit | undefined;
}
interface AudioData {
readonly duration: number;
readonly format: AudioSampleFormat | null;
readonly numberOfChannels: number;
readonly numberOfFrames: number;
readonly sampleRate: number;
readonly timestamp: number;
allocationSize(options: AudioDataCopyToOptions): number;
clone(): AudioData;
close(): void;
copyTo(
destination: AllowSharedBufferSource,
options: AudioDataCopyToOptions,
): void;
}
declare var AudioData: {
prototype: AudioData;
new (init: AudioDataInit): AudioData;
};
interface AudioDecoderEventMap {
dequeue: Event;
}
/** Available only in secure contexts. */
interface AudioDecoder {
readonly decodeQueueSize: number;
readonly state: CodecState;
ondequeue: ((this: AudioDecoder, ev: Event) => any) | null;
close(): void;
configure(config: AudioDecoderConfig): void;
decode(chunk: EncodedAudioChunk): void;
flush(): Promise<void>;
reset(): void;
addEventListener<K extends keyof AudioDecoderEventMap>(
type: K,
listener: (this: AudioDecoder, ev: AudioDecoderEventMap[K]) => any,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener<K extends keyof AudioDecoderEventMap>(
type: K,
listener: (this: AudioDecoder, ev: AudioDecoderEventMap[K]) => any,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
declare var AudioDecoder: {
prototype: AudioDecoder;
new (init: AudioDecoderInit): AudioDecoder;
isConfigSupported(config: AudioDecoderConfig): Promise<AudioDecoderSupport>;
};
interface AudioEncoderEventMap {
dequeue: Event;
}
/** Available only in secure contexts. */
interface AudioEncoder {
readonly encodeQueueSize: number;
readonly state: CodecState;
ondequeue: ((this: AudioEncoder, ev: Event) => any) | null;
close(): void;
configure(config: AudioEncoderConfig): void;
encode(data: AudioData): void;
flush(): Promise<void>;
reset(): void;
addEventListener<K extends keyof AudioEncoderEventMap>(
type: K,
listener: (this: AudioEncoder, ev: AudioEncoderEventMap[K]) => any,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener<K extends keyof AudioEncoderEventMap>(
type: K,
listener: (this: AudioEncoder, ev: AudioEncoderEventMap[K]) => any,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
declare var AudioEncoder: {
prototype: AudioEncoder;
new (init: AudioEncoderInit): AudioEncoder;
isConfigSupported(config: AudioEncoderConfig): Promise<AudioEncoderSupport>;
};
interface EncodedAudioChunk {
readonly byteLength: number;
readonly duration: number | null;
readonly timestamp: number;
readonly type: EncodedAudioChunkType;
copyTo(destination: AllowSharedBufferSource): void;
}
declare var EncodedAudioChunk: {
prototype: EncodedAudioChunk;
new (init: EncodedAudioChunkInit): EncodedAudioChunk;
};
interface EncodedVideoChunk {
readonly byteLength: number;
readonly duration: number | null;
readonly timestamp: number;
readonly type: EncodedVideoChunkType;
copyTo(destination: AllowSharedBufferSource): void;
}
declare var EncodedVideoChunk: {
prototype: EncodedVideoChunk;
new (init: EncodedVideoChunkInit): EncodedVideoChunk;
};
/** Available only in secure contexts. */
interface ImageDecoder {
readonly complete: boolean;
readonly completed: Promise<void>;
readonly tracks: ImageTrackList;
readonly type: string;
close(): void;
decode(options?: ImageDecodeOptions): Promise<ImageDecodeResult>;
reset(): void;
}
// declare var ImageDecoder: {
// prototype: ImageDecoder;
// new(init: ImageDecoderInit): ImageDecoder;
// isTypeSupported(type: string): Promise<boolean>;
// };
// interface ImageTrack {
// readonly animated: boolean;
// readonly frameCount: number;
// readonly repetitionCount: number;
// selected: boolean;
// }
// declare var ImageTrack: {
// prototype: ImageTrack;
// new(): ImageTrack;
// };
// interface ImageTrackList {
// readonly length: number;
// readonly ready: Promise<void>;
// readonly selectedIndex: number;
// readonly selectedTrack: ImageTrack | null;
// [index: number]: ImageTrack;
// }
// declare var ImageTrackList: {
// prototype: ImageTrackList;
// new(): ImageTrackList;
// };
interface VideoColorSpace {
readonly fullRange: boolean | null;
readonly matrix: VideoMatrixCoefficients | null;
readonly primaries: VideoColorPrimaries | null;
readonly transfer: VideoTransferCharacteristics | null;
toJSON(): VideoColorSpaceInit;
}
declare var VideoColorSpace: {
prototype: VideoColorSpace;
new (init?: VideoColorSpaceInit): VideoColorSpace;
};
interface VideoDecoderEventMap {
dequeue: Event;
}
/** Available only in secure contexts. */
interface VideoDecoder {
readonly decodeQueueSize: number;
readonly state: CodecState;
ondequeue: ((this: VideoDecoder, ev: Event) => any) | null;
close(): void;
configure(config: VideoDecoderConfig): void;
decode(chunk: EncodedVideoChunk): void;
flush(): Promise<void>;
reset(): void;
addEventListener<K extends keyof VideoDecoderEventMap>(
type: K,
listener: (this: VideoDecoder, ev: VideoDecoderEventMap[K]) => any,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener<K extends keyof VideoDecoderEventMap>(
type: K,
listener: (this: VideoDecoder, ev: VideoDecoderEventMap[K]) => any,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
declare var VideoDecoder: {
prototype: VideoDecoder;
new (init: VideoDecoderInit): VideoDecoder;
isConfigSupported(config: VideoDecoderConfig): Promise<VideoDecoderSupport>;
};
interface VideoEncoderEventMap {
dequeue: Event;
}
/** Available only in secure contexts. */
interface VideoEncoder {
readonly encodeQueueSize: number;
readonly state: CodecState;
close(): void;
ondequeue: ((this: VideoEncoder, ev: Event) => any) | null;
configure(config: VideoEncoderConfig): void;
encode(frame: VideoFrame, options?: VideoEncoderEncodeOptions): void;
flush(): Promise<void>;
reset(): void;
addEventListener<K extends keyof VideoEncoderEventMap>(
type: K,
listener: (this: VideoEncoder, ev: VideoEncoderEventMap[K]) => any,
options?: boolean | AddEventListenerOptions,
): void;
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
): void;
removeEventListener<K extends keyof VideoEncoderEventMap>(
type: K,
listener: (this: VideoEncoder, ev: VideoEncoderEventMap[K]) => any,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
declare var VideoEncoder: {
prototype: VideoEncoder;
new (init: VideoEncoderInit): VideoEncoder;
isConfigSupported(config: VideoEncoderConfig): Promise<VideoEncoderSupport>;
};
interface VideoFrame {
readonly codedHeight: number;
readonly codedRect: DOMRectReadOnly | null;
readonly codedWidth: number;
readonly colorSpace: VideoColorSpace;
readonly displayHeight: number;
readonly displayWidth: number;
readonly duration: number | null;
readonly format: VideoPixelFormat | null;
readonly timestamp: number;
readonly visibleRect: DOMRectReadOnly | null;
allocationSize(options?: VideoFrameCopyToOptions): number;
clone(): VideoFrame;
close(): void;
copyTo(
destination: AllowSharedBufferSource,
options?: VideoFrameCopyToOptions,
): Promise<PlaneLayout[]>;
}
declare var VideoFrame: {
prototype: VideoFrame;
new (source: CanvasImageSource, init?: VideoFrameInit): VideoFrame;
new (data: AllowSharedBufferSource, init: VideoFrameBufferInit): VideoFrame;
};
interface AudioDataOutputCallback {
(output: AudioData): void;
}
interface EncodedAudioChunkOutputCallback {
(output: EncodedAudioChunk, metadata: EncodedAudioChunkMetadata): void;
}
interface EncodedVideoChunkOutputCallback {
(chunk: EncodedVideoChunk, metadata: EncodedVideoChunkMetadata): void;
}
interface VideoFrameOutputCallback {
(output: VideoFrame): void;
}
interface WebCodecsErrorCallback {
(error: DOMException): void;
}
// type AllowSharedBufferSource = ArrayBuffer | ArrayBufferView;
// type BitrateMode = "constant" | "variable";
// type ImageBufferSource = ArrayBuffer | ArrayBufferView | ReadableStream;
// type AlphaOption = "discard" | "keep";
// type AudioSampleFormat = "f32" | "f32-planar" | "s16" | "s16-planar" | "s32" | "s32-planar" | "u8" | "u8-planar";
// type AvcBitstreamFormat = "annexb" | "avc";
// type CodecState = "closed" | "configured" | "unconfigured";
// type EncodedAudioChunkType = "delta" | "key";
// type EncodedVideoChunkType = "delta" | "key";
type HardwarePreference =
| "no-preference"
| "prefer-hardware"
| "prefer-software";
// type LatencyMode = "quality" | "realtime";
// type VideoColorPrimaries = "bt470bg" | "bt709" | "smpte170m";
// type VideoMatrixCoefficients = "bt470bg" | "bt709" | "rgb" | "smpte170m";
// type VideoPixelFormat = "BGRA" | "BGRX" | "I420" | "I420A" | "I422" | "I444" | "NV12" | "RGBA" | "RGBX";
// type VideoTransferCharacteristics = "bt709" | "iec61966-2-1" | "smpte170m";

View File

@@ -124,9 +124,15 @@ export interface UrlParams {
*/
password: string | null;
/**
* Whether we the app should use per participant keys for E2EE.
* Whether the app should use per participant keys for E2EE.
*/
perParticipantE2EE: boolean;
/**
* Whether the global JS controls for audio output devices should be enabled,
* allowing the list of output devices to be controlled by the app hosting
* Element Call.
*/
controlledAudioDevices: boolean;
/**
* Setting this flag skips the lobby and brings you in the call directly.
* In the widget this can be combined with preload to pass the device settings
@@ -173,6 +179,7 @@ export interface UrlParams {
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
@@ -281,6 +288,11 @@ export const getUrlParams = (
fontScale: Number.isNaN(fontScale) ? null : fontScale,
allowIceFallback: parser.getFlagParam("allowIceFallback"),
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
controlledAudioDevices: parser.getFlagParam(
"controlledAudioDevices",
// the deprecated property name
parser.getFlagParam("controlledMediaDevices"),
),
skipLobby: parser.getFlagParam(
"skipLobby",
isWidget && intent === UserIntent.StartNewCall,

View File

@@ -119,7 +119,7 @@ export const UserMenu: FC<Props> = ({
key={key}
Icon={Icon}
label={label}
data-test-id={dataTestid}
data-testid={dataTestid}
onSelect={() => onAction(key)}
/>
))}

View File

@@ -5,15 +5,55 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { Subject } from "rxjs";
import { BehaviorSubject, Subject } from "rxjs";
export interface Controls {
canEnterPip: () => boolean;
enablePip: () => void;
disablePip: () => void;
canEnterPip(): boolean;
enablePip(): void;
disablePip(): void;
/** @deprecated use setAvailableAudioDevices instead*/
setAvailableOutputDevices(devices: OutputDevice[]): void;
setAvailableAudioDevices(devices: OutputDevice[]): void;
/** @deprecated use setAudioDevice instead*/
setOutputDevice(id: string): void;
setAudioDevice(id: string): void;
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
onAudioDeviceSelect?: (id: string) => void;
/** @deprecated use setAudioEnabled instead*/
setOutputEnabled(enabled: boolean): void;
setAudioEnabled(enabled: boolean): void;
/** @deprecated use showNativeAudioDevicePicker instead*/
showNativeOutputDevicePicker?: () => void;
showNativeAudioDevicePicker?: () => void;
}
export interface OutputDevice {
id: string;
name: string;
forEarpiece?: boolean;
isEarpiece?: boolean;
isSpeaker?: boolean;
isExternalHeadset?: boolean;
}
/**
* 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);
/**
* This allows the os to mute the call if the user
* presses the volume down button when it is at the minimum volume.
*
* 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>();
window.controls = {
canEnterPip(): boolean {
@@ -27,4 +67,28 @@ window.controls = {
if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled$.next(false);
},
setAvailableAudioDevices(devices: OutputDevice[]): void {
availableOutputDevices$.next(devices);
},
setAudioDevice(id: string): void {
outputDevice$.next(id);
},
setAudioEnabled(enabled: boolean): void {
if (!setAudioEnabled$.observed)
throw new Error(
"Output controls are disabled. No setAudioEnabled$ observer",
);
setAudioEnabled$.next(enabled);
},
// wrappers for the deprecated controls fields
setOutputEnabled(enabled: boolean): void {
this.setAudioEnabled(enabled);
},
setAvailableOutputDevices(devices: OutputDevice[]): void {
this.setAvailableAudioDevices(devices);
},
setOutputDevice(id: string): void {
this.setAudioDevice(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 { BaseKeyProvider, createKeyMaterialFromBuffer } from "livekit-client";
import { BaseKeyProvider } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession,
@@ -44,20 +44,29 @@ export class MatrixKeyProvider extends BaseKeyProvider {
encryptionKeyIndex: number,
participantId: string,
): void => {
createKeyMaterialFromBuffer(encryptionKey).then(
(keyMaterial) => {
this.onSetEncryptionKey(keyMaterial, participantId, encryptionKeyIndex);
crypto.subtle
.importKey("raw", encryptionKey, "HKDF", false, [
"deriveBits",
"deriveKey",
])
.then(
(keyMaterial) => {
this.onSetEncryptionKey(
keyMaterial,
participantId,
encryptionKeyIndex,
);
logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
e,
);
},
);
logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
e,
);
},
);
};
}

View File

@@ -21,7 +21,7 @@ const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
const useInternalRoomSharedKey = (roomId: string): string | null => {
const key = getRoomSharedKeyLocalStorageKey(roomId);
const roomSharedKey = useLocalStorage(key)[0];
const [roomSharedKey] = useLocalStorage(key);
return roomSharedKey;
};

View File

@@ -136,6 +136,11 @@ export class Initializer {
lookup: () => getUrlParams().lang ?? undefined,
});
// Synchronise the HTML lang attribute with the i18next language
i18n.on("languageChanged", (lng) => {
document.documentElement.lang = lng;
});
await i18n
.use(Backend)
.use(languageDetector)

View File

@@ -0,0 +1,104 @@
/*
Copyright 2023, 2024 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 { afterEach, beforeEach, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import {
getTrackReferenceId,
type TrackReference,
} from "@livekit/components-core";
import { type RemoteAudioTrack } from "livekit-client";
import { type ReactNode } from "react";
import { useTracks } from "@livekit/components-react";
import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "./MediaDevicesContext";
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
import { mockTrack } from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
beforeEach(() => {
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
vi.mock("@livekit/components-react", async (importOriginal) => {
return {
...(await importOriginal()),
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
return (
<audio data-testid={"audio"}>
{getTrackReferenceId(props.trackRef)}
</audio>
);
},
useTracks: vi.fn(),
};
});
const tracks = [mockTrack("test:123")];
vi.mocked(useTracks).mockReturnValue(tracks);
it("should render for member", () => {
const { container, queryAllByTestId } = render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(1);
});
it("should not render without member", () => {
const { container, queryAllByTestId } = render(
<MatrixAudioRenderer
members={[{ sender: "othermember", deviceId: "123" }] as CallMembership[]}
/>,
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(0);
});
it("should not setup audioContext gain and pan if there is no need to.", () => {
render(
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
expect(audioTrack.setAudioContext).toHaveBeenCalledWith(undefined);
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledTimes(1);
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledWith([]);
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[]}
/>,
);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
expect(audioTrack.setAudioContext).toHaveBeenCalled();
expect(audioTrack.setWebAudioPlugins).toHaveBeenCalled();
expect(testAudioContext.gain.gain.value).toEqual(0.1);
expect(testAudioContext.pan.pan.value).toEqual(1);
});

View File

@@ -0,0 +1,212 @@
/*
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 { getTrackReferenceId } from "@livekit/components-core";
import { type RemoteAudioTrack, Track } from "livekit-client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
useTracks,
AudioTrack,
type AudioTrackProps,
} from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "./MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
export interface MatrixAudioRendererProps {
/**
* The list of participants to render audio for.
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
* that are not expected to be in the rtc session.
*/
members: CallMembership[];
/**
* If set to `true`, mutes all audio tracks rendered by the component.
* @remarks
* If set to `true`, the server will stop sending audio track data to the client.
*/
muted?: boolean;
}
/**
* The `MatrixAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app.
* It takes care of handling remote participants audio tracks and makes sure that microphones and screen share are audible.
*
* It also takes care of the earpiece audio configuration for iOS devices.
* This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio.
* @example
* ```tsx
* <LiveKitRoom>
* <MatrixAudioRenderer />
* </LiveKitRoom>
* ```
* @public
*/
export function MatrixAudioRenderer({
members,
muted,
}: MatrixAudioRendererProps): ReactNode {
const validIdentities = useMemo(
() =>
new Set(members?.map((member) => `${member.sender}:${member.deviceId}`)),
[members],
);
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.
*
* @param identity The identity of the track that is invalid
* @param validIdentities The list of valid identities
*/
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
if (loggedInvalidIdentities.current.has(identity)) return;
logger.warn(
`Audio track ${identity} has no matching matrix call member`,
`current members: ${Array.from(validIdentities.values())}`,
`track will not get rendered`,
);
loggedInvalidIdentities.current.add(identity);
};
const tracks = useTracks(
[
Track.Source.Microphone,
Track.Source.ScreenShareAudio,
Track.Source.Unknown,
],
{
updateOnlyOn: [],
onlySubscribed: true,
},
).filter((ref) => {
const isValid = validIdentities?.has(ref.participant.identity);
if (!isValid && !ref.participant.isLocal)
logInvalid(ref.participant.identity, validIdentities);
return (
!ref.participant.isLocal &&
ref.publication.kind === Track.Kind.Audio &&
isValid
);
});
// This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones.
// The Safari audio devices enumeration does not expose an earpiece audio device.
// We alternatively use the audioContext pan node to only use one of the stereo channels.
// This component does get additionally complicated because of a Safari bug.
// (see: https://bugs.webkit.org/show_bug.cgi?id=251532
// and the related issues: https://bugs.webkit.org/show_bug.cgi?id=237878
// and https://bugs.webkit.org/show_bug.cgi?id=231105)
//
// AudioContext gets stopped if the webview gets moved into the background.
// Once the phone is in standby audio playback will stop.
// So we can only use the pan trick only works is the phone is not in standby.
// If earpiece mode is not used we do not use audioContext to allow standby playback.
// shouldUseAudioContext is set to false if stereoPan === 0 to allow standby bluetooth playback.
const { pan: stereoPan, volume: volumeFactor } = useEarpieceAudioConfig();
const shouldUseAudioContext = stereoPan !== 0;
// initialize the potentially used audio context.
const [audioContext, setAudioContext] = useState<AudioContext | undefined>(
undefined,
);
useEffect(() => {
const ctx = new AudioContext();
setAudioContext(ctx);
return (): void => {
void ctx.close();
};
}, []);
const audioNodes = useMemo(
() => ({
gain: audioContext?.createGain(),
pan: audioContext?.createStereoPanner(),
}),
[audioContext],
);
// Simple effects to update the gain and pan node based on the props
useEffect(() => {
if (audioNodes.pan) audioNodes.pan.pan.value = stereoPan;
}, [audioNodes.pan, stereoPan]);
useEffect(() => {
if (audioNodes.gain) audioNodes.gain.gain.value = volumeFactor;
}, [audioNodes.gain, volumeFactor]);
return (
// We add all audio elements into one <div> for the browser developer tool experience/tidyness.
<div style={{ display: "none" }}>
{tracks.map((trackRef) => (
<AudioTrackWithAudioNodes
key={getTrackReferenceId(trackRef)}
trackRef={trackRef}
muted={muted}
audioContext={shouldUseAudioContext ? audioContext : undefined}
audioNodes={audioNodes}
/>
))}
</div>
);
}
interface StereoPanAudioTrackProps {
muted?: boolean;
audioContext?: AudioContext;
audioNodes: {
gain?: GainNode;
pan?: StereoPannerNode;
};
}
/**
* This wraps `livekit.AudioTrack` to allow adding audio nodes to a track.
* It main purpose is to remount the AudioTrack component when switching from
* audiooContext to normal audio playback.
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
* @param param0
* @returns
*/
function AudioTrackWithAudioNodes({
trackRef,
muted,
audioContext,
audioNodes,
...props
}: StereoPanAudioTrackProps &
AudioTrackProps &
React.RefAttributes<HTMLAudioElement>): ReactNode {
// This is used to unmount/remount the AudioTrack component.
// Mounting needs to happen after the audioContext is set.
// (adding the audio context when already mounted did not work outside strict mode)
const [trackReady, setTrackReady] = useReactiveState(
() => false,
// We only want the track to reset once both (audioNodes and audioContext) are set.
// for unsetting the audioContext its enough if one of the the is undefined.
[audioContext && audioNodes],
);
useEffect(() => {
if (!trackRef || trackReady) return;
const track = trackRef.publication.track as RemoteAudioTrack;
const useContext = audioContext && audioNodes.gain && audioNodes.pan;
track.setAudioContext(useContext ? audioContext : undefined);
track.setWebAudioPlugins(
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
);
setTrackReady(true);
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
return (
trackReady && <AudioTrack trackRef={trackRef} muted={muted} {...props} />
);
}

View File

@@ -1,5 +1,5 @@
/*
Copyright 2023, 2024 New Vector Ltd.
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.
@@ -17,8 +17,8 @@ import {
type JSX,
} from "react";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { map, startWith } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { combineLatest, map, startWith } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import {
@@ -26,20 +26,33 @@ import {
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 MediaDevice {
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.
*/
@@ -50,23 +63,69 @@ export interface MediaDevice {
select: (deviceId: string) => void;
}
export interface MediaDevices {
audioInput: MediaDevice;
audioOutput: MediaDevice;
videoInput: MediaDevice;
interface InputDevices {
audioInput: MediaDeviceHandle;
videoInput: MediaDeviceHandle;
startUsingDeviceNames: () => void;
stopUsingDeviceNames: () => void;
usingNames: boolean;
}
function useMediaDevice(
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,
): MediaDevice {
// Make sure we don't needlessly reset to a device observer without names,
// once permissions are already given
): 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
@@ -102,11 +161,13 @@ function useMediaDevice(
// 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.size &&
!available.has("") &&
!available.has("default")
!available.has("default") &&
available.size
)
available = new Map([
["", { type: "default", name: availableRaw[0]?.label || null }],
@@ -118,26 +179,13 @@ function useMediaDevice(
return available;
}),
),
[kind, deviceObserver$],
[deviceObserver$, kind],
),
);
const [preferredId, select] = useSetting(setting);
const selectedId = 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]);
const selectedId = useSelectedId(available, preferredId);
const selectedGroupId = useObservableEagerState(
useMemo(
() =>
@@ -155,6 +203,7 @@ function useMediaDevice(
() => ({
available,
selectedId,
useAsEarpiece: false,
selectedGroupId,
select,
}),
@@ -162,12 +211,14 @@ function useMediaDevice(
);
}
export const deviceStub: MediaDevice = {
export const deviceStub: MediaDeviceHandle = {
available: new Map(),
selectedId: undefined,
selectedGroupId: undefined,
select: () => {},
useAsEarpiece: false,
};
export const devicesStub: MediaDevices = {
audioInput: deviceStub,
audioOutput: deviceStub,
@@ -178,26 +229,17 @@ export const devicesStub: MediaDevices = {
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
interface Props {
children: JSX.Element;
}
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
function useInputDevices(): InputDevices {
// Counts the number of callers currently using device names.
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
const usingNames = numCallersUsingNames > 0;
const audioInput = useMediaDevice(
const audioInput = useMediaDeviceHandle(
"audioinput",
audioInputSetting,
usingNames,
);
const audioOutput = useMediaDevice(
"audiooutput",
audioOutputSetting,
usingNames,
);
const videoInput = useMediaDevice(
const videoInput = useMediaDeviceHandle(
"videoinput",
videoInputSetting,
usingNames,
@@ -212,17 +254,52 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
[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,
audioOutput: controlledAudioDevices
? controlledAudioOutput
: webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
}),
[
audioInput,
audioOutput,
controlledAudioDevices,
controlledAudioOutput,
webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
@@ -236,6 +313,80 @@ export const MediaDevicesProvider: FC<Props> = ({ children }) => {
);
};
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);
@@ -255,3 +406,30 @@ export const useMediaDeviceNames = (
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

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useState } from "react";
import { test, vi } from "vitest";
import { describe, expect, test, vi, vitest } from "vitest";
import {
ConnectionError,
ConnectionErrorReason,
@@ -15,6 +15,7 @@ import {
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { defer, sleep } from "matrix-js-sdk/lib/utils";
import { useECConnectionState } from "./useECConnectionState";
import { type SFUConfig } from "./openIDSFU";
@@ -57,7 +58,7 @@ test.each<[string, ConnectionError]>([
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
[],
);
useECConnectionState({}, false, mockRoom, sfuConfig);
useECConnectionState("default", false, mockRoom, sfuConfig);
return <button onClick={connect}>Connect</button>;
};
@@ -73,3 +74,111 @@ test.each<[string, ConnectionError]>([
screen.getByText("Insufficient capacity");
},
);
describe("Leaking connection prevention", () => {
function createTestComponent(mockRoom: Room): FC {
const TestComponent: FC = () => {
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
undefined,
);
const connect = useCallback(
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
[],
);
useECConnectionState("default", false, mockRoom, sfuConfig);
return <button onClick={connect}>Connect</button>;
};
return TestComponent;
}
test("Should cancel pending connections when the component is unmounted", async () => {
const connectCall = vi.fn();
const pendingConnection = defer<void>();
// let pendingDisconnection = defer<void>()
const disconnectMock = vi.fn();
const mockRoom = {
on: () => {},
off: () => {},
once: () => {},
connect: async () => {
connectCall.call(undefined);
return await pendingConnection.promise;
},
disconnect: disconnectMock,
localParticipant: {
getTrackPublication: () => {},
createTracks: () => [],
},
} as unknown as Room;
const TestComponent = createTestComponent(mockRoom);
const { unmount } = render(<TestComponent />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Connect" }));
expect(connectCall).toHaveBeenCalled();
// unmount while the connection is pending
unmount();
// resolve the pending connection
pendingConnection.resolve();
await vitest.waitUntil(
() => {
return disconnectMock.mock.calls.length > 0;
},
{
timeout: 1000,
interval: 100,
},
);
// There should be some cleaning up to avoid leaking an open connection
expect(disconnectMock).toHaveBeenCalledTimes(1);
});
test("Should cancel about to open but not yet opened connection", async () => {
const createTracksCall = vi.fn();
const pendingCreateTrack = defer<void>();
// let pendingDisconnection = defer<void>()
const disconnectMock = vi.fn();
const connectMock = vi.fn();
const mockRoom = {
on: () => {},
off: () => {},
once: () => {},
connect: connectMock,
disconnect: disconnectMock,
localParticipant: {
getTrackPublication: () => {},
createTracks: async () => {
createTracksCall.call(undefined);
await pendingCreateTrack.promise;
return [];
},
},
} as unknown as Room;
const TestComponent = createTestComponent(mockRoom);
const { unmount } = render(<TestComponent />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Connect" }));
expect(createTracksCall).toHaveBeenCalled();
// unmount while createTracks is pending
unmount();
// resolve createTracks
pendingCreateTrack.resolve();
// Yield to the event loop to let the connection attempt finish
await sleep(100);
// The operation should have been aborted before even calling connect.
expect(connectMock).not.toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
type AudioCaptureOptions,
ConnectionError,
ConnectionState,
type LocalTrack,
@@ -25,6 +24,7 @@ import {
InsufficientCapacityError,
UnknownCallError,
} from "../utils/errors.ts";
import { AbortHandle } from "../utils/abortHandle.ts";
declare global {
interface Window {
@@ -59,7 +59,8 @@ async function doConnect(
livekitRoom: Room,
sfuConfig: SFUConfig,
audioEnabled: boolean,
audioOptions: AudioCaptureOptions,
initialDeviceId: string | undefined,
abortHandle: AbortHandle,
): Promise<void> {
// Always create an audio track manually.
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
@@ -82,19 +83,40 @@ async function doConnect(
let preCreatedAudioTrack: LocalTrack | undefined;
try {
const audioTracks = await livekitRoom!.localParticipant.createTracks({
audio: audioOptions,
audio: { deviceId: initialDeviceId },
});
if (audioTracks.length < 1) {
logger.info("Tried to pre-create local audio track but got no tracks");
} else {
preCreatedAudioTrack = audioTracks[0];
}
// There was a yield point previously (awaiting for the track to be created) so we need to check
// if the operation was cancelled and stop connecting if needed.
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
);
preCreatedAudioTrack?.stop();
return;
}
logger.info("Pre-created microphone track");
} catch (e) {
logger.error("Failed to pre-create microphone track", e);
}
if (!audioEnabled) await preCreatedAudioTrack?.mute();
if (!audioEnabled) {
await preCreatedAudioTrack?.mute();
// There was a yield point. Check if the operation was cancelled and stop connecting.
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
);
preCreatedAudioTrack?.stop();
return;
}
}
// check again having awaited for the track to create
if (
@@ -107,9 +129,18 @@ async function doConnect(
return;
}
logger.info("Connecting & publishing");
logger.info("[Lifecycle] Connecting & publishing");
try {
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect",
);
livekitRoom?.disconnect().catch((err) => {
logger.error("Failed to disconnect from SFU", err);
});
return;
}
} catch (e) {
preCreatedAudioTrack?.stop();
logger.debug("Stopped precreated audio tracks.");
@@ -137,13 +168,16 @@ async function connectAndPublish(
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
try {
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
// Due to stability issues on Firefox we are testing the effect of different
// timeouts, and allow these values to be set through the console
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
websocketTimeout: window.websocketTimeout ?? 45000,
});
logger.info(`[Lifecycle] ... connected to livekit room`);
} catch (e) {
logger.error("[Lifecycle] Failed to connect", e);
// LiveKit uses 503 to indicate that the server has hit its track limits.
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
// It also errors with a status code of 200 (yes, really) for room
@@ -184,7 +218,7 @@ async function connectAndPublish(
}
export function useECConnectionState(
initialAudioOptions: AudioCaptureOptions,
initialDeviceId: string | undefined,
initialAudioEnabled: boolean,
livekitRoom?: Room,
sfuConfig?: SFUConfig,
@@ -247,6 +281,22 @@ export function useECConnectionState(
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
// Protection against potential leaks, where the component to be unmounted and there is
// still a pending doConnect promise. This would lead the user to still be in the call even
// if the component is unmounted.
const abortHandlesBag = useRef(new Set<AbortHandle>());
// This is a cleanup function that will be called when the component is about to be unmounted.
// It will cancel all abortHandles in the bag
useEffect(() => {
const bag = abortHandlesBag.current;
return (): void => {
bag.forEach((handle) => {
handle.abort();
});
};
}, []);
// Id we are transitioning from a valid config to another valid one, we need
// to explicitly switch focus
useEffect(() => {
@@ -273,11 +323,14 @@ export function useECConnectionState(
// always capturing audio: it helps keep bluetooth headsets in the right mode and
// mobile browsers to know we're doing a call.
setIsInDoConnect(true);
const abortHandle = new AbortHandle();
abortHandlesBag.current.add(abortHandle);
doConnect(
livekitRoom!,
sfuConfig!,
initialAudioEnabled,
initialAudioOptions,
initialDeviceId,
abortHandle,
)
.catch((e) => {
if (e instanceof ElementCallError) {
@@ -286,14 +339,17 @@ export function useECConnectionState(
setError(new UnknownCallError(e));
} else logger.error("Failed to connect to SFU", e);
})
.finally(() => setIsInDoConnect(false));
.finally(() => {
abortHandlesBag.current.delete(abortHandle);
setIsInDoConnect(false);
});
}
currentSFUConfig.current = Object.assign({}, sfuConfig);
}, [
sfuConfig,
livekitRoom,
initialAudioOptions,
initialDeviceId,
initialAudioEnabled,
doFocusSwitch,
]);

View File

@@ -25,7 +25,7 @@ import { defaultLiveKitOptions } from "./options";
import { type SFUConfig } from "./openIDSFU";
import { type MuteStates } from "../room/MuteStates";
import {
type MediaDevice,
type MediaDeviceHandle,
type MediaDevices,
useMediaDevices,
} from "./MediaDevicesContext";
@@ -42,18 +42,21 @@ import {
} from "./TrackProcessorContext";
import { useInitial } from "../useInitial";
import { observeTrackReference$ } from "../state/MediaViewModel";
import { useUrlParams } from "../UrlParams";
interface UseLivekitResult {
livekitRoom?: Room;
connState: ECConnectionState;
}
export function useLiveKit(
export function useLivekit(
rtcSession: MatrixRTCSession,
muteStates: MuteStates,
sfuConfig: SFUConfig | undefined,
e2eeSystem: EncryptionSystem,
): UseLivekitResult {
const { controlledAudioDevices } = useUrlParams();
const e2eeOptions = useMemo((): E2EEManagerOptions | undefined => {
if (e2eeSystem.kind === E2eeType.NONE) return undefined;
@@ -128,6 +131,7 @@ export function useLiveKit(
// @livekit/components-react. JSON.stringify() is used in deps of a
// useEffect() with an argument that references itself, if E2EE is enabled
const room = useMemo(() => {
logger.info("[LivekitRooms] Create LiveKit room with options", roomOptions);
const r = new Room(roomOptions);
r.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
logger.error("Failed to set E2EE enabled on room", e);
@@ -155,9 +159,7 @@ export function useLiveKit(
);
const connectionState = useECConnectionState(
{
deviceId: initialDevices.current.audioInput.selectedId,
},
initialDevices.current.audioInput.selectedId,
initialMuteStates.current.audio.enabled,
room,
sfuConfig,
@@ -305,8 +307,15 @@ export function useLiveKit(
useEffect(() => {
// Sync the requested devices with LiveKit's devices
if (room !== undefined && connectionState === ConnectionState.Connected) {
const syncDevice = (kind: MediaDeviceKind, device: MediaDevice): void => {
if (
room !== undefined &&
connectionState === ConnectionState.Connected &&
!controlledAudioDevices
) {
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
@@ -362,7 +371,7 @@ export function useLiveKit(
syncDevice("audiooutput", devices.audioOutput);
syncDevice("videoinput", devices.videoInput);
}
}, [room, devices, connectionState]);
}, [room, devices, connectionState, controlledAudioDevices]);
return {
connState: connectionState,

View File

@@ -29,7 +29,7 @@ window.setLKLogLevel = setLKLogLevel;
initRageshake().catch((e) => {
logger.error("Failed to initialize rageshake", e);
});
setLKLogLevel("warn");
setLKLogLevel("info");
setLKLogExtension((level, msg, context) => {
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
global.mx_rage_logger.log(level, "livekit", msg, context);

View File

@@ -47,12 +47,15 @@ export const callEventAudioSounds = prefetchSounds({
export function CallEventAudioRenderer({
vm,
muted,
}: {
vm: CallViewModel;
muted?: boolean;
}): ReactNode {
const audioEngineCtx = useAudioContext({
sounds: callEventAudioSounds,
latencyHint: "interactive",
muted,
});
const audioEngineRef = useLatest(audioEngineCtx);

View File

@@ -24,6 +24,7 @@ import {
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom";
import { useObservableEagerState } from "observable-hooks";
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
@@ -62,11 +63,12 @@ import {
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import {
useExperimentalToDeviceTransportSetting,
useNewMembershipManagerSetting as useNewMembershipManagerSetting,
useNewMembershipManager as useNewMembershipManagerSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
useSetting,
} from "../settings/settings";
import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
declare global {
interface Window {
@@ -103,12 +105,14 @@ export const GroupCallView: FC<Props> = ({
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const muteAllAudio = useObservableEagerState(muteAllAudio$);
const leaveSoundContext = useLatest(
useAudioContext({
sounds: callEventAudioSounds,
latencyHint: "interactive",
muted: muteAllAudio,
}),
);
// This should use `useEffectEvent` (only available in experimental versions)
@@ -118,6 +122,13 @@ export const GroupCallView: FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
logger.info("[Lifecycle] GroupCallView Component mounted");
return (): void => {
logger.info("[Lifecycle] GroupCallView Component unmounted");
};
}, []);
useEffect(() => {
window.rtcSession = rtcSession;
return (): void => {

View File

@@ -0,0 +1,265 @@
/*
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 {
beforeEach,
describe,
expect,
it,
type MockedFunction,
vi,
} from "vitest";
import { act, render, type RenderResult } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { ConnectionState, type LocalParticipant } from "livekit-client";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { type MuteStates } from "./MuteStates";
import { InCallView } from "./InCallView";
import {
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockRemoteParticipant,
mockRtcMembership,
type MockRTCSession,
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local } from "../utils/test-fixtures";
import {
developerMode as developerModeSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
} from "../settings/settings";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
// vi.hoisted(() => {
// localStorage = {} as unknown as Storage;
// });
vi.hoisted(
() =>
(global.ImageData = class MockImageData {
public data: number[] = [];
} as unknown as typeof ImageData),
);
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
vi.mock("../tile/GridTile");
vi.mock("../tile/SpotlightTile");
vi.mock("@livekit/components-react");
vi.mock("../e2ee/sharedKeyManagement");
vi.mock("../livekit/MatrixAudioRenderer");
vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}],
}));
const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
const localParticipant = mockLocalParticipant({
identity: "@local:example.org:AAAAAA",
});
const remoteParticipant = mockRemoteParticipant({
identity: "@alice:example.org:AAAAAA",
});
const carol = mockMatrixRoomMember(localRtcMember);
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar";
let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
beforeEach(() => {
vi.clearAllMocks();
// MatrixAudioRenderer is tested separately.
(
MatrixAudioRenderer as MockedFunction<typeof MatrixAudioRenderer>
).mockImplementation((_props) => {
return <div>mocked: MatrixAudioRenderer</div>;
});
(
useLocalParticipant as MockedFunction<typeof useLocalParticipant>
).mockImplementation(
() =>
({
isScreenShareEnabled: false,
localParticipant: localRtcMember as unknown as LocalParticipant,
}) as unknown as ReturnType<typeof useLocalParticipant>,
);
useRoomEncryptionSystemMock =
useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock;
useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE });
});
function createInCallView(): RenderResult & {
rtcSession: MockRTCSession;
} {
const client = {
getUser: () => null,
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null),
} as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vi.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
client,
roomId,
getMember: (userId) => roomMembers.get(userId) ?? null,
getMxcAvatarUrl: () => null,
hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
getCanonicalAlias: () => null,
currentState: {
getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState,
});
const muteState = {
audio: { enabled: false },
video: { enabled: false },
} as MuteStates;
const livekitRoom = mockLivekitRoom(
{
localParticipant,
},
{
remoteParticipants$: of([remoteParticipant]),
},
);
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
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>
</BrowserRouter>,
);
return {
...renderResult,
rtcSession,
};
}
describe("InCallView", () => {
describe("rendering", () => {
it("renders", () => {
const { container } = createInCallView();
expect(container).toMatchSnapshot();
});
});
describe("toDevice label", () => {
it("is shown if setting activated and room encrypted", () => {
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(true);
const { getByText } = createInCallView();
expect(getByText("using to Device key transport")).toBeInTheDocument();
});
it("is not shown in unenecrypted room", () => {
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.NONE,
});
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(true);
const { queryByText } = createInCallView();
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is hidden once fallback was triggered", async () => {
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(true);
const { rtcSession, queryByText } = createInCallView();
expect(queryByText("using to Device key transport")).toBeInTheDocument();
expect(rtcSession).toBeDefined();
await act(() =>
rtcSession.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, {
toDevice: true,
room: true,
}),
);
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is not shown if setting is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(false);
developerModeSetting.setValue(true);
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
const { queryByText } = createInCallView();
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
it("is not shown if developer mode is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(false);
useRoomEncryptionSystemMock.mockReturnValue({
kind: E2eeType.PER_PARTICIPANT,
});
const { queryByText } = createInCallView();
expect(
queryByText("using to Device key transport"),
).not.toBeInTheDocument();
});
});
});

View File

@@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
RoomAudioRenderer,
RoomContext,
useLocalParticipant,
} from "@livekit/components-react";
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { Text } from "@vector-im/compound-web";
import { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk";
@@ -56,7 +52,7 @@ import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useLiveKit } from "../livekit/useLiveKit";
import { useLivekit } from "../livekit/useLivekit.ts";
import { useWakeLock } from "../useWakeLock";
import { useMergedRefs } from "../useMergedRefs";
import { type MuteStates } from "./MuteStates";
@@ -73,7 +69,10 @@ import {
import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
import { SpotlightTile } from "../tile/SpotlightTile";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import {
useRoomEncryptionSystem,
type EncryptionSystem,
} from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { makeGridLayout } from "../grid/GridLayout";
import {
@@ -96,12 +95,15 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
debugTileLayout as debugTileLayoutSetting,
useExperimentalToDeviceTransportSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
developerMode as developerModeSetting,
useSetting,
} from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader";
import { ConnectionLostError } from "../utils/errors.ts";
import { useTypedEventEmitter } from "../useEvents.ts";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -114,7 +116,7 @@ export interface ActiveCallProps
export const ActiveCall: FC<ActiveCallProps> = (props) => {
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLiveKit(
const { livekitRoom, connState } = useLivekit(
props.rtcSession,
props.muteStates,
sfuConfig,
@@ -127,10 +129,23 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const [vm, setVm] = useState<CallViewModel | null>(null);
useEffect(() => {
logger.info(
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
);
return (): void => {
livekitRoom?.disconnect().catch((e) => {
logger.error("Failed to disconnect from livekit room", e);
});
logger.info(
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
);
livekitRoom
?.disconnect()
.then(() => {
logger.info(
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
);
})
.catch((e) => {
logger.error("[Lifecycle] Failed to disconnect from livekit room", e);
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -220,19 +235,34 @@ export const InCallView: FC<InCallViewProps> = ({
room: livekitRoom,
});
const [toDeviceEncryptionSetting] = useSetting(
useExperimentalToDeviceTransportSetting,
);
const [showToDeviceEncryption, setShowToDeviceEncryption] = useState(
() => toDeviceEncryptionSetting,
);
useEffect(() => {
setShowToDeviceEncryption(toDeviceEncryptionSetting);
}, [toDeviceEncryptionSetting]);
const muteAllAudio = useObservableEagerState(muteAllAudio$);
// This seems like it might be enough logic to use move it into the call view model?
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
useTypedEventEmitter(
rtcSession,
RoomAndToDeviceEvents.EnabledTransportsChanged,
(enabled) => setShowToDeviceEncryption(enabled.to_device),
(enabled) => setDidFallbackToRoomKey(enabled.room),
);
const [developerMode] = useSetting(developerModeSetting);
const [useExperimentalToDeviceTransport] = useSetting(
useExperimentalToDeviceTransportSetting,
);
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
const showToDeviceEncryption = useMemo(
() =>
developerMode &&
useExperimentalToDeviceTransport &&
encryptionSystem.kind === E2eeType.PER_PARTICIPANT &&
!didFallbackToRoomKey,
[
developerMode,
useExperimentalToDeviceTransport,
encryptionSystem.kind,
didFallbackToRoomKey,
],
);
const toggleMicrophone = useCallback(
@@ -693,10 +723,13 @@ export const InCallView: FC<InCallViewProps> = ({
</Text>
)
}
<RoomAudioRenderer />
<MatrixAudioRenderer
members={rtcSession.memberships}
muted={muteAllAudio}
/>
{renderContent()}
<CallEventAudioRenderer vm={vm} />
<ReactionsAudioRenderer vm={vm} />
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsOverlay vm={vm} />
{footer}
{layout.type !== "pip" && (

View File

@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useMemo, useState, type JSX } from "react";
import {
type FC,
useCallback,
useMemo,
useState,
type JSX,
useEffect,
} from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk";
import { Button } from "@vector-im/compound-web";
@@ -72,6 +79,13 @@ export const LobbyView: FC<Props> = ({
onShareClick,
waitingForInvite,
}) => {
useEffect(() => {
logger.info("[Lifecycle] GroupCallView Component mounted");
return (): void => {
logger.info("[Lifecycle] GroupCallView Component unmounted");
};
}, []);
const { t } = useTranslation();
usePageTitle(matrixInfo.roomName);

View File

@@ -14,7 +14,7 @@ import userEvent from "@testing-library/user-event";
import { useMuteStates } from "./MuteStates";
import {
type DeviceLabel,
type MediaDevice,
type MediaDeviceHandle,
type MediaDevices,
MediaDevicesContext,
} from "../livekit/MediaDevicesContext";
@@ -73,12 +73,13 @@ const mockCamera: MediaDeviceInfo = {
},
};
function mockDevices(available: Map<string, DeviceLabel>): MediaDevice {
function mockDevices(available: Map<string, DeviceLabel>): MediaDeviceHandle {
return {
available,
selectedId: "",
selectedGroupId: "",
select: (): void => {},
useAsEarpiece: false,
};
}

View File

@@ -16,7 +16,7 @@ import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type MediaDevice,
type MediaDeviceHandle,
useMediaDevices,
} from "../livekit/MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
@@ -53,7 +53,7 @@ export interface MuteStates {
}
function useMuteState(
device: MediaDevice,
device: MediaDeviceHandle,
enabledByDefault: () => boolean,
): MuteState {
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(

View File

@@ -21,8 +21,8 @@ import { act, type ReactNode } from "react";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import {
playReactionsSound,
soundEffectVolumeSetting,
playReactionsSound as playReactionsSoundSetting,
soundEffectVolume as soundEffectVolumeSetting,
} from "../settings/settings";
import { useAudioContext } from "../useAudioContext";
import { GenericReaction, ReactionSet } from "../reactions";
@@ -50,7 +50,7 @@ vitest.mock("../soundUtils");
afterEach(() => {
vitest.resetAllMocks();
playReactionsSound.setValue(playReactionsSound.defaultValue);
playReactionsSoundSetting.setValue(playReactionsSoundSetting.defaultValue);
soundEffectVolumeSetting.setValue(soundEffectVolumeSetting.defaultValue);
});
@@ -74,7 +74,7 @@ beforeEach(() => {
test("preloads all audio elements", () => {
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
playReactionsSound.setValue(true);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
expect(prefetchSounds).toHaveBeenCalledOnce();
});
@@ -84,7 +84,7 @@ test("will play an audio sound when there is a reaction", () => {
local,
alice,
]);
playReactionsSound.setValue(true);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
@@ -110,7 +110,7 @@ test("will play the generic audio sound when there is soundless reaction", () =>
local,
alice,
]);
playReactionsSound.setValue(true);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect
@@ -136,7 +136,7 @@ test("will play multiple audio sounds when there are multiple different reaction
local,
alice,
]);
playReactionsSound.setValue(true);
playReactionsSoundSetting.setValue(true);
render(<TestComponent vm={vm} />);
// Find the first reaction with a sound effect

View File

@@ -24,8 +24,10 @@ const soundMap = Object.fromEntries([
export function ReactionsAudioRenderer({
vm,
muted,
}: {
vm: CallViewModel;
muted?: boolean;
}): ReactNode {
const [shouldPlay] = useSetting(playReactionsSound);
const [soundCache, setSoundCache] = useState<ReturnType<
@@ -34,6 +36,7 @@ export function ReactionsAudioRenderer({
const audioEngineCtx = useAudioContext({
sounds: soundCache,
latencyHint: "interactive",
muted,
});
const audioEngineRef = useLatest(audioEngineCtx);

View File

@@ -0,0 +1,181 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InCallView > rendering > renders 1`] = `
<div>
<div
class="inRoom"
>
<div
class="header filler"
/>
<div>
mocked: MatrixAudioRenderer
</div>
<div
class="scrollingGrid grid"
>
<div
class="layer"
>
<div
class="container slot"
data-id="1"
>
<div
class="slot local slot"
data-block-alignment="start"
data-id="0"
data-inline-alignment="end"
/>
</div>
</div>
</div>
<div
class="fixedGrid grid"
>
<div />
</div>
<div
class="container"
/>
<div
class="footer"
>
<div
class="buttons"
>
<button
aria-disabled="false"
aria-labelledby=":r0:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.949 7.949 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8.001 8.001 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5L8 8Zm9.417 6.583 1.478 1.477A7.963 7.963 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583ZM8.073 5.238l7.793 7.793c.087-.329.134-.674.134-1.031V6a4 4 0 0 0-7.927-.762Z"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-labelledby=":r5:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A1.992 1.992 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414ZM18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715v1.45Z"
/>
</svg>
</button>
<button
aria-labelledby=":ra:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.731 2C13.432 2 14 2.568 14 3.269c0 .578.396 1.074.935 1.286.085.034.17.07.253.106.531.23 1.162.16 1.572-.25a1.269 1.269 0 0 1 1.794 0l1.034 1.035a1.269 1.269 0 0 1 0 1.794c-.41.41-.48 1.04-.248 1.572.036.084.07.168.105.253.212.539.708.935 1.286.935.701 0 1.269.568 1.269 1.269v1.462c0 .701-.568 1.269-1.269 1.269-.578 0-1.074.396-1.287.935-.033.085-.068.17-.104.253-.232.531-.161 1.162.248 1.572a1.269 1.269 0 0 1 0 1.794l-1.034 1.034a1.269 1.269 0 0 1-1.794 0c-.41-.41-1.04-.48-1.572-.248a7.935 7.935 0 0 1-.253.105c-.539.212-.935.708-.935 1.286 0 .701-.568 1.269-1.269 1.269H11.27c-.702 0-1.27-.568-1.27-1.269 0-.578-.396-1.074-.935-1.287a7.975 7.975 0 0 1-.253-.104c-.531-.232-1.162-.161-1.572.248a1.269 1.269 0 0 1-1.794 0l-1.034-1.034a1.269 1.269 0 0 1 0-1.794c.41-.41.48-1.04.249-1.572a7.89 7.89 0 0 1-.106-.253C4.343 14.396 3.847 14 3.27 14 2.568 14 2 13.432 2 12.731V11.27c0-.702.568-1.27 1.269-1.27.578 0 1.074-.396 1.286-.935.034-.085.07-.17.106-.253.23-.531.16-1.162-.25-1.572a1.269 1.269 0 0 1 0-1.794l1.035-1.034a1.269 1.269 0 0 1 1.794 0c.41.41 1.04.48 1.572.249a7.93 7.93 0 0 1 .253-.106c.539-.212.935-.708.935-1.286C10 2.568 10.568 2 11.269 2h1.462ZM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z"
/>
</svg>
</button>
<button
aria-labelledby=":rf:"
class="_button_i91xf_17 endCall _has-icon_i91xf_66 _icon-only_i91xf_59 _destructive_i91xf_116"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m2.765 16.02-2.47-2.416A1.018 1.018 0 0 1 0 12.852c0-.304.098-.555.295-.751a15.64 15.64 0 0 1 5.316-3.786A15.89 15.89 0 0 1 12 7c2.237 0 4.367.443 6.39 1.329a15.977 15.977 0 0 1 5.315 3.772c.197.196.295.447.295.751 0 .305-.098.555-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.067 1.067 0 0 1-.322-.376 1.066 1.066 0 0 1-.108-.483v-2.27a13.593 13.593 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.175.175c-.715.116-1.422.29-2.12.523v2.271c0 .179-.036.34-.108.483a1.066 1.066 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107Z"
/>
</svg>
</button>
</div>
<div
class="toggle layout"
>
<input
aria-labelledby=":rk:"
name="layout"
type="radio"
value="spotlight"
/>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 5h14v8h-5a1 1 0 0 0-1 1v5H5V5Zm10 14v-4h4v4h-4ZM5 21h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2Z"
/>
</svg>
<input
aria-labelledby=":rp:"
checked=""
name="layout"
type="radio"
value="grid"
/>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 11a.967.967 0 0 1-.712-.287A.968.968 0 0 1 3 10V4c0-.283.096-.52.288-.712A.968.968 0 0 1 4 3h6a.97.97 0 0 1 .713.288A.968.968 0 0 1 11 4v6c0 .283-.096.52-.287.713A.968.968 0 0 1 10 11H4Zm5-2V5H5v4h4Zm5 12a.968.968 0 0 1-.713-.288A.968.968 0 0 1 13 20v-6c0-.283.096-.52.287-.713A.968.968 0 0 1 14 13h6a.97.97 0 0 1 .712.287c.192.192.288.43.288.713v6c0 .283-.096.52-.288.712A.968.968 0 0 1 20 21h-6Zm5-2v-4h-4v4h4ZM4 21a.967.967 0 0 1-.712-.288A.968.968 0 0 1 3 20v-6a.97.97 0 0 1 .288-.713A.967.967 0 0 1 4 13h6c.283 0 .52.096.713.287.191.192.287.43.287.713v6a.97.97 0 0 1-.287.712A.968.968 0 0 1 10 21H4Zm5-2v-4H5v4h4Zm5-8a.968.968 0 0 1-.713-.287A.968.968 0 0 1 13 10V4a.97.97 0 0 1 .287-.712A.968.968 0 0 1 14 3h6c.283 0 .52.096.712.288A.965.965 0 0 1 21 4v6a.97.97 0 0 1-.288.713A.968.968 0 0 1 20 11h-6Zm5-2V5h-4v4h4Z"
/>
</svg>
</div>
</div>
</div>
</div>
`;

View File

@@ -121,10 +121,9 @@ export async function enterRTCSession(
...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
membershipServerSideExpiryTimeout:
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
membershipKeepAlivePeriod:
matrixRtcSessionConfig?.membership_keep_alive_period,
networkErrorRetryMs: matrixRtcSessionConfig?.membership_keep_alive_period,
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
useExperimentalToDeviceTransport,
},

View File

@@ -15,8 +15,10 @@ import {
debugTileLayout as debugTileLayoutSetting,
showNonMemberTiles as showNonMemberTilesSetting,
showConnectionStats as showConnectionStatsSetting,
useNewMembershipManagerSetting,
useExperimentalToDeviceTransportSetting,
useNewMembershipManager as useNewMembershipManagerSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
} from "./settings";
import type { MatrixClient } from "matrix-js-sdk";
import type { Room as LivekitRoom } from "livekit-client";
@@ -45,10 +47,16 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
useNewMembershipManagerSetting,
);
const [alwaysShowIphoneEarpiece, setAlwaysShowIphoneEarpiece] = useSetting(
alwaysShowIphoneEarpieceSetting,
);
const [
useExperimentalToDeviceTransport,
setUseExperimentalToDeviceTransport,
] = useSetting(useExperimentalToDeviceTransportSetting);
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
const urlParams = useUrlParams();
const sfuUrl = useMemo((): URL | null => {
@@ -175,6 +183,34 @@ export const DeveloperSettingsTab: FC<Props> = ({ client, livekitRoom }) => {
)}
/>
</FieldRow>
<FieldRow>
<InputField
id="muteAllAudio"
type="checkbox"
label={t("developer_mode.mute_all_audio")}
checked={muteAllAudio}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setMuteAllAudio(event.target.checked);
},
[setMuteAllAudio],
)}
/>
</FieldRow>{" "}
<FieldRow>
<InputField
id="alwaysShowIphoneEarpiece"
type="checkbox"
label={t("developer_mode.always_show_iphone_earpiece")}
checked={alwaysShowIphoneEarpiece}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setAlwaysShowIphoneEarpiece(event.target.checked);
},
[setAlwaysShowIphoneEarpiece],
)}
/>{" "}
</FieldRow>
{livekitRoom ? (
<>
<p>

View File

@@ -22,17 +22,20 @@ import {
} from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { type MediaDevice } from "../livekit/MediaDevicesContext";
import {
EARPIECE_CONFIG_ID,
type MediaDeviceHandle,
} from "../livekit/MediaDevicesContext";
import styles from "./DeviceSelection.module.css";
interface Props {
devices: MediaDevice;
device: MediaDeviceHandle;
title: string;
numberedLabel: (number: number) => string;
}
export const DeviceSelection: FC<Props> = ({
devices,
device,
title,
numberedLabel,
}) => {
@@ -40,12 +43,13 @@ export const DeviceSelection: FC<Props> = ({
const groupId = useId();
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
devices.select(e.target.value);
device.select(e.target.value);
},
[devices],
[device],
);
if (devices.available.size == 0) return null;
// There is no need to show the menu if there is no choice that can be made.
if (device.available.size <= 1) return null;
return (
<div className={styles.selection}>
@@ -60,7 +64,7 @@ export const DeviceSelection: FC<Props> = ({
</Heading>
<Separator className={styles.separator} />
<div className={styles.options}>
{[...devices.available].map(([id, label]) => {
{[...device.available].map(([id, label]) => {
let labelText: ReactNode;
switch (label.type) {
case "name":
@@ -85,6 +89,16 @@ export const DeviceSelection: FC<Props> = ({
</Trans>
);
break;
case "earpiece":
labelText = t("settings.devices.earpiece");
break;
}
let isSelected = false;
if (device.useAsEarpiece) {
isSelected = id === EARPIECE_CONFIG_ID;
} else {
isSelected = id === device.selectedId;
}
return (
@@ -93,7 +107,7 @@ export const DeviceSelection: FC<Props> = ({
name={groupId}
control={
<RadioControl
checked={id === devices.selectedId}
checked={isSelected}
onChange={onChange}
value={id}
/>

View File

@@ -8,8 +8,9 @@ Please see LICENSE in the repository root for full details.
import { type FC, type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk";
import { Root as Form, Separator } from "@vector-im/compound-web";
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
import { type Room as LivekitRoom } from "livekit-client";
import { useObservableEagerState } from "observable-hooks";
import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css";
@@ -19,11 +20,12 @@ import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import {
useMediaDevices,
useMediaDeviceNames,
iosDeviceMenu$,
} from "../livekit/MediaDevicesContext";
import { widget } from "../widget";
import {
useSetting,
soundEffectVolumeSetting,
soundEffectVolume as soundEffectVolumeSetting,
backgroundBlur as backgroundBlurSetting,
developerMode,
} from "./settings";
@@ -34,6 +36,7 @@ import { useTrackProcessor } from "../livekit/TrackProcessorContext";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { FieldRow, InputField } from "../input/Input";
import { useSubmitRageshake } from "./submit-rageshake";
import { useUrlParams } from "../UrlParams";
type SettingsTab =
| "audio"
@@ -98,29 +101,52 @@ export const SettingsModal: FC<Props> = ({
useMediaDeviceNames(devices, open);
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
const [showDeveloperSettingsTab] = useSetting(developerMode);
const { available: isRageshakeAvailable } = useSubmitRageshake();
// For controlled devices, we will not show the input section:
// Controlled media devices are used on mobile platforms, where input and output are grouped into
// a single device. These are called "headset" or "speaker" (or similar) but contain both input and output.
// On EC, we decided that it is less confusing for the user if they see those options in the output section
// rather than the input section.
const { controlledAudioDevices } = useUrlParams();
// If we are on iOS we will show a button to open the native audio device picker.
const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$);
const audioTab: Tab<SettingsTab> = {
key: "audio",
name: t("common.audio"),
content: (
<>
<Form>
{!controlledAudioDevices && (
<DeviceSelection
device={devices.audioInput}
title={t("settings.devices.microphone")}
numberedLabel={(n) =>
t("settings.devices.microphone_numbered", { n })
}
/>
)}
{iosDeviceMenu && (
<Button
onClick={(e): void => {
e.preventDefault();
window.controls.showNativeAudioDevicePicker?.();
// call deprecated method for backwards compatibility.
window.controls.showNativeOutputDevicePicker?.();
}}
>
{t("settings.devices.change_device_button")}
</Button>
)}
<DeviceSelection
devices={devices.audioInput}
title={t("settings.devices.microphone")}
numberedLabel={(n) =>
t("settings.devices.microphone_numbered", { n })
}
/>
<DeviceSelection
devices={devices.audioOutput}
device={devices.audioOutput}
title={t("settings.devices.speaker")}
numberedLabel={(n) => t("settings.devices.speaker_numbered", { n })}
/>
<div className={styles.volumeSlider}>
<label>{t("settings.audio_tab.effect_volume_label")}</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p>
@@ -146,7 +172,7 @@ export const SettingsModal: FC<Props> = ({
<>
<Form>
<DeviceSelection
devices={devices.videoInput}
device={devices.videoInput}
title={t("settings.devices.camera")}
numberedLabel={(n) => t("settings.devices.camera_numbered", { n })}
/>

View File

@@ -44,6 +44,9 @@ export class Setting<T> {
this._value$.next(value);
localStorage.setItem(this.key, JSON.stringify(value));
};
public readonly getValue = (): T => {
return this._value$.getValue();
};
}
/**
@@ -110,19 +113,26 @@ export const playReactionsSound = new Setting<boolean>(
true,
);
export const soundEffectVolumeSetting = new Setting<number>(
export const soundEffectVolume = new Setting<number>(
"sound-effect-volume",
0.5,
);
export const useNewMembershipManagerSetting = new Setting<boolean>(
export const useNewMembershipManager = new Setting<boolean>(
"new-membership-manager",
true,
);
export const useExperimentalToDeviceTransportSetting = new Setting<boolean>(
export const useExperimentalToDeviceTransport = new Setting<boolean>(
"experimental-to-device-transport",
true,
);
export const muteAllAudio = new Setting<boolean>("mute-all-audio", false);
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
export const alwaysShowIphoneEarpiece = new Setting<boolean>(
"always-show-iphone-earpiece",
false,
);

View File

@@ -0,0 +1,36 @@
/*
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 { test, vi } from "vitest";
import { expect } from "vitest";
import { setAudioEnabled$ } from "../controls";
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
import { muteAllAudio$ } from "./MuteAllAudioModel";
test("muteAllAudio$", () => {
const valueMock = vi.fn();
const muteAllAudio = muteAllAudio$.subscribe((value) => {
valueMock(value);
});
setAudioEnabled$.next(false);
setAudioEnabled$.next(true);
muteAllAudioSetting.setValue(false);
muteAllAudioSetting.setValue(true);
setAudioEnabled$.next(false);
muteAllAudio.unsubscribe();
expect(valueMock).toHaveBeenCalledTimes(6);
expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]);
expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false);
expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true);
expect(valueMock).toHaveBeenNthCalledWith(4, false); // muteAllAudioSetting.setValue(false);
expect(valueMock).toHaveBeenNthCalledWith(5, true); // muteAllAudioSetting.setValue(true);
expect(valueMock).toHaveBeenNthCalledWith(6, true); // setAudioEnabled$.next(false);
});

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 { combineLatest, startWith } from "rxjs";
import { setAudioEnabled$ } from "../controls";
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
/**
* This can transition into sth more complete: `GroupCallViewModel.ts`
*/
export const muteAllAudio$ = combineLatest(
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
);

View File

@@ -5,14 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vitest, afterEach } from "vitest";
import { expect, vi, afterEach, beforeEach, test } from "vitest";
import { type FC } from "react";
import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import userEvent, { type UserEvent } from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom";
import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext";
import { useAudioContext } from "./useAudioContext";
import { soundEffectVolumeSetting } from "./settings/settings";
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
const staticSounds = Promise.resolve({
aSound: new ArrayBuffer(0),
@@ -38,62 +39,81 @@ const TestComponent: FC = () => {
</>
);
};
class MockAudioContext {
public static testContext: MockAudioContext;
public constructor() {
MockAudioContext.testContext = this;
}
public gain = vitest.mocked(
{
connect: () => {},
gain: {
setValueAtTime: vitest.fn(),
},
},
true,
const TestComponentWrapper: FC = () => {
return (
<BrowserRouter>
<TestComponent />
</BrowserRouter>
);
};
public setSinkId = vitest.fn().mockResolvedValue(undefined);
public decodeAudioData = vitest.fn().mockReturnValue(1);
public createBufferSource = vitest.fn().mockReturnValue(
vitest.mocked({
const gainNode = vi.mocked(
{
connect: (node: AudioNode) => node,
gain: {
setValueAtTime: vi.fn(),
value: 1,
},
},
true,
);
const panNode = vi.mocked(
{
connect: (node: AudioNode) => node,
pan: {
setValueAtTime: vi.fn(),
value: 0,
},
},
true,
);
/**
* A shared audio context test instance.
* It can also be used to mock the `AudioContext` constructor in tests:
* `vi.stubGlobal("AudioContext", () => testAudioContext);`
*/
export const testAudioContext = {
gain: gainNode,
pan: panNode,
setSinkId: vi.fn().mockResolvedValue(undefined),
decodeAudioData: vi.fn().mockReturnValue(1),
createBufferSource: vi.fn().mockReturnValue(
vi.mocked({
connect: (v: unknown) => v,
start: () => {},
addEventListener: (_name: string, cb: () => void) => cb(),
}),
);
public createGain = vitest.fn().mockReturnValue(this.gain);
public close = vitest.fn().mockResolvedValue(undefined);
}
),
createGain: vi.fn().mockReturnValue(gainNode),
createStereoPanner: vi.fn().mockReturnValue(panNode),
close: vi.fn().mockResolvedValue(undefined),
};
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
let user: UserEvent;
beforeEach(() => {
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
user = userEvent.setup();
});
afterEach(() => {
vitest.unstubAllGlobals();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
test("can play a single sound", async () => {
const user = userEvent.setup();
vitest.stubGlobal("AudioContext", MockAudioContext);
const { findByText } = render(<TestComponent />);
const { findByText } = render(<TestComponentWrapper />);
await user.click(await findByText("Valid sound"));
expect(
MockAudioContext.testContext.createBufferSource,
).toHaveBeenCalledOnce();
expect(testAudioContext.createBufferSource).toHaveBeenCalledOnce();
});
test("will ignore sounds that are not registered", async () => {
const user = userEvent.setup();
vitest.stubGlobal("AudioContext", MockAudioContext);
const { findByText } = render(<TestComponent />);
const { findByText } = render(<TestComponentWrapper />);
await user.click(await findByText("Invalid sound"));
expect(
MockAudioContext.testContext.createBufferSource,
).not.toHaveBeenCalled();
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
});
test("will use the correct device", () => {
vitest.stubGlobal("AudioContext", MockAudioContext);
render(
<MediaDevicesContext.Provider
value={{
@@ -103,30 +123,56 @@ test("will use the correct device", () => {
selectedGroupId: "",
available: new Map(),
select: () => {},
useAsEarpiece: false,
},
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
}}
>
<TestComponent />
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
);
expect(
MockAudioContext.testContext.createBufferSource,
).not.toHaveBeenCalled();
expect(MockAudioContext.testContext.setSinkId).toHaveBeenCalledWith(
"chosen-device",
);
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
expect(testAudioContext.setSinkId).toHaveBeenCalledWith("chosen-device");
});
test("will use the correct volume level", async () => {
const user = userEvent.setup();
vitest.stubGlobal("AudioContext", MockAudioContext);
soundEffectVolumeSetting.setValue(0.33);
const { findByText } = render(<TestComponent />);
const { findByText } = render(<TestComponentWrapper />);
await user.click(await findByText("Valid sound"));
expect(
MockAudioContext.testContext.gain.gain.setValueAtTime,
).toHaveBeenCalledWith(0.33, 0);
expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith(
0.33,
0,
);
expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(0, 0);
});
test("will use the pan if earpiece is selected", async () => {
const { findByText } = render(
<MediaDevicesContext.Provider
value={{
audioInput: deviceStub,
audioOutput: {
selectedId: "chosen-device",
selectedGroupId: "",
available: new Map(),
select: () => {},
useAsEarpiece: true,
},
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
}}
>
<TestComponentWrapper />
</MediaDevicesContext.Provider>,
);
await user.click(await findByText("Valid sound"));
expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(1, 0);
expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith(
soundEffectVolumeSetting.getValue() * 0.1,
0,
);
});

View File

@@ -9,11 +9,15 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { useState, useEffect } from "react";
import {
soundEffectVolumeSetting as effectSoundVolumeSetting,
soundEffectVolume as soundEffectVolumeSetting,
useSetting,
} from "./settings/settings";
import { useMediaDevices } from "./livekit/MediaDevicesContext";
import {
useEarpieceAudioConfig,
useMediaDevices,
} from "./livekit/MediaDevicesContext";
import { type PrefetchedSounds } from "./soundUtils";
import { useUrlParams } from "./UrlParams";
/**
* Play a sound though a given AudioContext. Will take
@@ -28,12 +32,15 @@ async function playSound(
ctx: AudioContext,
buffer: AudioBuffer,
volume: number,
stereoPan: number,
): Promise<void> {
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, 0);
const pan = ctx.createStereoPanner();
pan.pan.setValueAtTime(stereoPan, 0);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(gain).connect(ctx.destination);
src.connect(gain).connect(pan).connect(ctx.destination);
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
src.start();
return p;
@@ -47,6 +54,7 @@ interface Props<S extends string> {
*/
sounds: PrefetchedSounds<S> | null;
latencyHint: AudioContextLatencyCategory;
muted?: boolean;
}
interface UseAudioContext<S> {
@@ -62,8 +70,9 @@ interface UseAudioContext<S> {
export function useAudioContext<S extends string>(
props: Props<S>,
): UseAudioContext<S> | null {
const [effectSoundVolume] = useSetting(effectSoundVolumeSetting);
const devices = useMediaDevices();
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
const { audioOutput } = useMediaDevices();
const { controlledAudioDevices } = useUrlParams();
const [audioContext, setAudioContext] = useState<AudioContext>();
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
@@ -102,26 +111,37 @@ export function useAudioContext<S extends string>(
// Update the sink ID whenever we change devices.
useEffect(() => {
if (audioContext && "setSinkId" in audioContext) {
if (
audioContext &&
"setSinkId" in audioContext &&
!controlledAudioDevices
) {
// 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(devices.audioOutput.selectedId).catch((ex) => {
audioContext.setSinkId(audioOutput.selectedId).catch((ex) => {
logger.warn("Unable to change sink for audio context", ex);
});
}
}, [audioContext, devices]);
}, [audioContext, audioOutput.selectedId, controlledAudioDevices]);
const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig();
// Don't return a function until we're ready.
if (!audioContext || !audioBuffers) {
if (!audioContext || !audioBuffers || props.muted) {
return null;
}
return {
playSound: async (name): Promise<void> => {
if (!audioBuffers[name]) {
logger.debug(`Tried to play a sound that wasn't buffered (${name})`);
return;
}
return playSound(audioContext, audioBuffers[name], effectSoundVolume);
return playSound(
audioContext,
audioBuffers[name],
soundEffectVolume * earpieceVolume,
earpiecePan,
);
},
};
}

View File

@@ -42,6 +42,14 @@ export const useLocalStorage = (
};
export const setLocalStorageItem = (key: string, value: string): void => {
// Avoid unnecessary updates. Not avoiding them so can cause unexpected state updates across hooks.
// For instance:
// - In call view uses useRoomEncryptionSystem
// - This will set the key again.
// - All other instances of useRoomEncryptionSystem will now do a useMemo update of the e2eeSystem
// - because the dependency `storedPassword = useInternalRoomSharedKey(roomId);` would change.
if (localStorage.getItem(key) === value) return;
localStorage.setItem(key, value);
localStorageBus.emit(key, value);
};

18
src/utils/abortHandle.ts Normal file
View File

@@ -0,0 +1,18 @@
/*
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.
*/
export class AbortHandle {
public constructor(private aborted = false) {}
public abort(): void {
this.aborted = true;
}
public isAborted(): boolean {
return this.aborted;
}
}

View File

@@ -27,8 +27,14 @@ import {
type RemoteParticipant,
type RemoteTrackPublication,
type Room as LivekitRoom,
Track,
} from "livekit-client";
import { randomUUID } from "crypto";
import {
type RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { type TrackReference } from "@livekit/components-core";
import {
LocalUserMediaViewModel,
@@ -269,8 +275,8 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
}
export class MockRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent,
MatrixRTCSessionEventHandlerMap
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
> {
public readonly statistics = {
counters: {},
@@ -305,3 +311,24 @@ export class MockRTCSession extends TypedEventEmitter<
return this;
}
}
export const mockTrack = (identity: string): TrackReference =>
({
participant: {
identity,
},
publication: {
kind: Track.Kind.Audio,
source: "mic",
trackSid: "123",
track: {
attach: vi.fn(),
detach: vi.fn(),
setAudioContext: vi.fn(),
setWebAudioPlugins: vi.fn(),
setVolume: vi.fn(),
},
},
track: {},
source: {},
}) as unknown as TrackReference;

View File

@@ -3,7 +3,7 @@
"target": "es2022",
"module": "es2022",
"jsx": "react-jsx",
"lib": ["es2022", "dom", "dom.iterable"],
"lib": ["es2024", "dom", "dom.iterable"],
// From Matrix-JS-SDK
"strict": true,

281
yarn.lock
View File

@@ -2527,12 +2527,12 @@ __metadata:
languageName: node
linkType: hard
"@livekit/protocol@npm:1.36.1, @livekit/protocol@npm:^1.33.0":
version: 1.36.1
resolution: "@livekit/protocol@npm:1.36.1"
"@livekit/protocol@npm:1.38.0, @livekit/protocol@npm:^1.38.0":
version: 1.38.0
resolution: "@livekit/protocol@npm:1.38.0"
dependencies:
"@bufbuild/protobuf": "npm:^1.10.0"
checksum: 10c0/bb2e56785c542446bef3e2f2fd20b33d01db43b786be87ccb834feee8a664fd32c8231e249b4e1915d7a8eda13af0d59eea479fa710327079a1a370daf05c42e
checksum: 10c0/ca64d4f984853054ff60574730b08a761afcd3bdc084e5218663e54b0e7f395aa2022d9d15d982fa094bbc0179cb19ef6a96ec74b1aa3265d118a85d1a4fde33
languageName: node
linkType: hard
@@ -2547,10 +2547,10 @@ __metadata:
languageName: node
linkType: hard
"@matrix-org/matrix-sdk-crypto-wasm@npm:^14.0.1":
version: 14.0.1
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:14.0.1"
checksum: 10c0/6e98abb61f8d6c43b26f04e83db92b39db74352861495eda9ac472b2f58411a45b2f150e4361c44c6800f98f99e89350d11941e87b9bf22204c9cab83ca93e27
"@matrix-org/matrix-sdk-crypto-wasm@npm:^14.2.0":
version: 14.2.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:14.2.0"
checksum: 10c0/cc51417d71ffe506401dbb85ff4930bf89b33b6718c9052ba3cc1b2989e989e5e7e6fceea7c5fb58576d64e7a5c947c4cece89dbfa785083d293508c80e0713d
languageName: node
linkType: hard
@@ -4007,142 +4007,142 @@ __metadata:
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-android-arm-eabi@npm:4.37.0"
"@rollup/rollup-android-arm-eabi@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-android-arm-eabi@npm:4.40.2"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@rollup/rollup-android-arm64@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-android-arm64@npm:4.37.0"
"@rollup/rollup-android-arm64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-android-arm64@npm:4.40.2"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-arm64@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-darwin-arm64@npm:4.37.0"
"@rollup/rollup-darwin-arm64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-darwin-arm64@npm:4.40.2"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-x64@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-darwin-x64@npm:4.37.0"
"@rollup/rollup-darwin-x64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-darwin-x64@npm:4.40.2"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-arm64@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-freebsd-arm64@npm:4.37.0"
"@rollup/rollup-freebsd-arm64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-freebsd-arm64@npm:4.40.2"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-x64@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-freebsd-x64@npm:4.37.0"
"@rollup/rollup-freebsd-x64@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-freebsd-x64@npm:4.40.2"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-gnueabihf@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.37.0"
"@rollup/rollup-linux-arm-gnueabihf@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.40.2"
conditions: os=linux & cpu=arm & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-musleabihf@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.37.0"
"@rollup/rollup-linux-arm-musleabihf@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.40.2"
conditions: os=linux & cpu=arm & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-gnu@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.37.0"
"@rollup/rollup-linux-arm64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.40.2"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-musl@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.37.0"
"@rollup/rollup-linux-arm64-musl@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.40.2"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-loongarch64-gnu@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.37.0"
"@rollup/rollup-linux-loongarch64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.40.2"
conditions: os=linux & cpu=loong64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-powerpc64le-gnu@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.37.0"
"@rollup/rollup-linux-powerpc64le-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.40.2"
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-gnu@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.37.0"
"@rollup/rollup-linux-riscv64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.40.2"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-musl@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.37.0"
"@rollup/rollup-linux-riscv64-musl@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.40.2"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-s390x-gnu@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.37.0"
"@rollup/rollup-linux-s390x-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.40.2"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-gnu@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.37.0"
"@rollup/rollup-linux-x64-gnu@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.40.2"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-musl@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-linux-x64-musl@npm:4.37.0"
"@rollup/rollup-linux-x64-musl@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-linux-x64-musl@npm:4.40.2"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-win32-arm64-msvc@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.37.0"
"@rollup/rollup-win32-arm64-msvc@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.40.2"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-win32-ia32-msvc@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.37.0"
"@rollup/rollup-win32-ia32-msvc@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.40.2"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rollup/rollup-win32-x64-msvc@npm:4.37.0":
version: 4.37.0
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.37.0"
"@rollup/rollup-win32-x64-msvc@npm:4.40.2":
version: 4.40.2
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.40.2"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -4621,7 +4621,30 @@ __metadata:
languageName: node
linkType: hard
"@types/estree@npm:1.0.6, @types/estree@npm:^1.0.0":
"@types/dom-mediacapture-transform@npm:^0.1.11":
version: 0.1.11
resolution: "@types/dom-mediacapture-transform@npm:0.1.11"
dependencies:
"@types/dom-webcodecs": "npm:*"
checksum: 10c0/19c76d54cf31aa2a925011fc5f973dff9a10bdecfdf2285e5e568e61850a0fa2b8c9f1807a1462cbefd57ec26d32eeaa9c359117aca9d9fe7f0d6f2fff33f51e
languageName: node
linkType: hard
"@types/dom-webcodecs@npm:*":
version: 0.1.15
resolution: "@types/dom-webcodecs@npm:0.1.15"
checksum: 10c0/1407f0352156c99c9b4378fb4c0c799b061520d031903a7f359ad09a6f706cc1fd56bafb272bb1a3decffcb32e54a51d2f07442eb72622464a950cff7f9e8862
languageName: node
linkType: hard
"@types/estree@npm:1.0.7":
version: 1.0.7
resolution: "@types/estree@npm:1.0.7"
checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c
languageName: node
linkType: hard
"@types/estree@npm:^1.0.0":
version: 1.0.6
resolution: "@types/estree@npm:1.0.6"
checksum: 10c0/cdfd751f6f9065442cd40957c07fd80361c962869aa853c1c2fd03e101af8b9389d8ff4955a43a6fcfa223dd387a089937f95be0f3eec21ca527039fd2d9859a
@@ -6921,7 +6944,7 @@ __metadata:
"@formatjs/intl-segmenter": "npm:^11.7.3"
"@livekit/components-core": "npm:^0.12.0"
"@livekit/components-react": "npm:^2.0.0"
"@livekit/protocol": "npm:^1.33.0"
"@livekit/protocol": "npm:^1.38.0"
"@livekit/track-processors": "npm:^0.5.5"
"@mediapipe/tasks-vision": "npm:^0.10.18"
"@opentelemetry/api": "npm:^1.4.0"
@@ -6944,6 +6967,7 @@ __metadata:
"@testing-library/react": "npm:^16.0.0"
"@testing-library/user-event": "npm:^14.5.1"
"@types/content-type": "npm:^1.1.5"
"@types/dom-mediacapture-transform": "npm:^0.1.11"
"@types/grecaptcha": "npm:^3.0.9"
"@types/jsdom": "npm:^21.1.7"
"@types/lodash-es": "npm:^4.17.12"
@@ -6980,11 +7004,11 @@ __metadata:
i18next-parser: "npm:^9.1.0"
jsdom: "npm:^26.0.0"
knip: "npm:^5.27.2"
livekit-client: "npm:^2.11.3"
livekit-client: "npm:^2.13.0"
lodash-es: "npm:^4.17.21"
loglevel: "npm:^1.9.1"
matrix-js-sdk: "github:matrix-org/matrix-js-sdk#19b1b901f575755d29d1fe03ca48cbf7c1cae05c"
matrix-widget-api: "npm:1.11.0"
matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=develop"
matrix-widget-api: "npm:^1.13.0"
normalize.css: "npm:^8.0.1"
observable-hooks: "npm:^4.2.3"
pako: "npm:^2.0.4"
@@ -7832,6 +7856,18 @@ __metadata:
languageName: node
linkType: hard
"fdir@npm:^6.4.4":
version: 6.4.4
resolution: "fdir@npm:6.4.4"
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
checksum: 10c0/6ccc33be16945ee7bc841e1b4178c0b4cf18d3804894cb482aa514651c962a162f96da7ffc6ebfaf0df311689fb70091b04dd6caffe28d56b9ebdc0e7ccadfdd
languageName: node
linkType: hard
"fflate@npm:^0.4.8":
version: 0.4.8
resolution: "fflate@npm:0.4.8"
@@ -9350,12 +9386,12 @@ __metadata:
languageName: node
linkType: hard
"livekit-client@npm:^2.11.3":
version: 2.11.3
resolution: "livekit-client@npm:2.11.3"
"livekit-client@npm:^2.13.0":
version: 2.13.0
resolution: "livekit-client@npm:2.13.0"
dependencies:
"@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.36.1"
"@livekit/protocol": "npm:1.38.0"
events: "npm:^3.3.0"
loglevel: "npm:^1.9.2"
sdp-transform: "npm:^2.15.0"
@@ -9363,7 +9399,7 @@ __metadata:
tslib: "npm:2.8.1"
typed-emitter: "npm:^2.1.0"
webrtc-adapter: "npm:^9.0.1"
checksum: 10c0/d56444f31c107b46ccd5532038ac77bd21038042910619008267c17894f1d3f054262ae2354d89df6fe0ba325aba01909b0612ad4c290906487c40d91641f6e4
checksum: 10c0/b5b7b37eea56681e543d754ee5df63651e45a78462d1f0c26334f0aa525761fefc86d5ad85513e9345bbb4d6821fc95dded5f1d7c008da2b558a14905cbd9ac2
languageName: node
linkType: hard
@@ -9574,12 +9610,12 @@ __metadata:
languageName: node
linkType: hard
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#19b1b901f575755d29d1fe03ca48cbf7c1cae05c":
version: 37.4.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=19b1b901f575755d29d1fe03ca48cbf7c1cae05c"
"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"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^14.0.1"
"@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"
@@ -9593,17 +9629,17 @@ __metadata:
sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6"
uuid: "npm:11"
checksum: 10c0/68a30a113059ba052b2e66502abcd9805f9a18a1bfd1d209203d728b36508af257a57e6248fb237c7018c81bfbe1ec78fa17aea8968c8af0729ea935398dcf8b
checksum: 10c0/2877e7c5b2779200b48f3152bb7b510e58899b1e779e7e6d2bc1a236ed178fa8858f0547b644a2029f48f55dc7cc3954f48e2598c8963d45293c8280ccc23039
languageName: node
linkType: hard
"matrix-widget-api@npm:1.11.0":
version: 1.11.0
resolution: "matrix-widget-api@npm:1.11.0"
"matrix-widget-api@npm:^1.10.0, matrix-widget-api@npm:^1.13.0":
version: 1.13.1
resolution: "matrix-widget-api@npm:1.13.1"
dependencies:
"@types/events": "npm:^3.0.0"
events: "npm:^3.2.0"
checksum: 10c0/ac35ed8fb2b5df6d186beb64b5faf907ec113d708ad8353bdd8f9196fb3da255561b8807e2e845f39d4cf7ae6af36f90b54c230a0a100556ac7ebd8f18bee848
checksum: 10c0/25ded744922755b3eb65f4e171cf6cff1a2e0fe43fc3fecbb13e565e41d8af066daa817dd2c3c7d921b996af399eec3b23df70ab7b682cf422d9cee7ca202512
languageName: node
linkType: hard
@@ -11550,31 +11586,31 @@ __metadata:
languageName: node
linkType: hard
"rollup@npm:^4.30.1":
version: 4.37.0
resolution: "rollup@npm:4.37.0"
"rollup@npm:^4.34.9":
version: 4.40.2
resolution: "rollup@npm:4.40.2"
dependencies:
"@rollup/rollup-android-arm-eabi": "npm:4.37.0"
"@rollup/rollup-android-arm64": "npm:4.37.0"
"@rollup/rollup-darwin-arm64": "npm:4.37.0"
"@rollup/rollup-darwin-x64": "npm:4.37.0"
"@rollup/rollup-freebsd-arm64": "npm:4.37.0"
"@rollup/rollup-freebsd-x64": "npm:4.37.0"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.37.0"
"@rollup/rollup-linux-arm-musleabihf": "npm:4.37.0"
"@rollup/rollup-linux-arm64-gnu": "npm:4.37.0"
"@rollup/rollup-linux-arm64-musl": "npm:4.37.0"
"@rollup/rollup-linux-loongarch64-gnu": "npm:4.37.0"
"@rollup/rollup-linux-powerpc64le-gnu": "npm:4.37.0"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.37.0"
"@rollup/rollup-linux-riscv64-musl": "npm:4.37.0"
"@rollup/rollup-linux-s390x-gnu": "npm:4.37.0"
"@rollup/rollup-linux-x64-gnu": "npm:4.37.0"
"@rollup/rollup-linux-x64-musl": "npm:4.37.0"
"@rollup/rollup-win32-arm64-msvc": "npm:4.37.0"
"@rollup/rollup-win32-ia32-msvc": "npm:4.37.0"
"@rollup/rollup-win32-x64-msvc": "npm:4.37.0"
"@types/estree": "npm:1.0.6"
"@rollup/rollup-android-arm-eabi": "npm:4.40.2"
"@rollup/rollup-android-arm64": "npm:4.40.2"
"@rollup/rollup-darwin-arm64": "npm:4.40.2"
"@rollup/rollup-darwin-x64": "npm:4.40.2"
"@rollup/rollup-freebsd-arm64": "npm:4.40.2"
"@rollup/rollup-freebsd-x64": "npm:4.40.2"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.40.2"
"@rollup/rollup-linux-arm-musleabihf": "npm:4.40.2"
"@rollup/rollup-linux-arm64-gnu": "npm:4.40.2"
"@rollup/rollup-linux-arm64-musl": "npm:4.40.2"
"@rollup/rollup-linux-loongarch64-gnu": "npm:4.40.2"
"@rollup/rollup-linux-powerpc64le-gnu": "npm:4.40.2"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.40.2"
"@rollup/rollup-linux-riscv64-musl": "npm:4.40.2"
"@rollup/rollup-linux-s390x-gnu": "npm:4.40.2"
"@rollup/rollup-linux-x64-gnu": "npm:4.40.2"
"@rollup/rollup-linux-x64-musl": "npm:4.40.2"
"@rollup/rollup-win32-arm64-msvc": "npm:4.40.2"
"@rollup/rollup-win32-ia32-msvc": "npm:4.40.2"
"@rollup/rollup-win32-x64-msvc": "npm:4.40.2"
"@types/estree": "npm:1.0.7"
fsevents: "npm:~2.3.2"
dependenciesMeta:
"@rollup/rollup-android-arm-eabi":
@@ -11621,7 +11657,7 @@ __metadata:
optional: true
bin:
rollup: dist/bin/rollup
checksum: 10c0/2e00382e08938636edfe0a7547ea2eaa027205dc0b6ff85d8b82be0fbe55a4ef88a1995fee2a5059e33dbccf12d1376c236825353afb89c96298cc95c5160a46
checksum: 10c0/cbe9b766891da74fbf7c3b50420bb75102e5c59afc0ea45751f7e43a581d2cd93367763f521f820b72e341cf1f6b9951fbdcd3be67a1b0aa774b754525a8b9c7
languageName: node
linkType: hard
@@ -12454,6 +12490,16 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.13":
version: 0.2.13
resolution: "tinyglobby@npm:0.2.13"
dependencies:
fdir: "npm:^6.4.4"
picomatch: "npm:^4.0.2"
checksum: 10c0/ef07dfaa7b26936601d3f6d999f7928a4d1c6234c5eb36896bb88681947c0d459b7ebe797022400e555fe4b894db06e922b95d0ce60cb05fd827a0a66326b18c
languageName: node
linkType: hard
"tinypool@npm:^1.0.2":
version: 1.0.2
resolution: "tinypool@npm:1.0.2"
@@ -13162,13 +13208,16 @@ __metadata:
linkType: hard
"vite@npm:^5.0.0 || ^6.0.0, vite@npm:^6.0.0":
version: 6.2.6
resolution: "vite@npm:6.2.6"
version: 6.3.5
resolution: "vite@npm:6.3.5"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.4.4"
fsevents: "npm:~2.3.3"
picomatch: "npm:^4.0.2"
postcss: "npm:^8.5.3"
rollup: "npm:^4.30.1"
rollup: "npm:^4.34.9"
tinyglobby: "npm:^0.2.13"
peerDependencies:
"@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: ">=1.21.0"
@@ -13209,7 +13258,7 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/68a2ed3e61bdd654c59b817b4f3203065241c66d1739faa707499130f3007bc3a666c7a8320a4198e275e62b5e4d34d9b78a6533f69e321d366e76f5093b2071
checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c
languageName: node
linkType: hard