Merge branch 'livekit' into toger5/otel-remove-experiment
This commit is contained in:
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: ["matrix-org", "rxjs"],
|
plugins: ["matrix-org", "rxjs", "jsdoc"],
|
||||||
extends: [
|
extends: [
|
||||||
"plugin:matrix-org/react",
|
"plugin:matrix-org/react",
|
||||||
"plugin:matrix-org/a11y",
|
"plugin:matrix-org/a11y",
|
||||||
@@ -26,6 +26,13 @@ module.exports = {
|
|||||||
node: true,
|
node: true,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
"jsdoc/no-types": "error",
|
||||||
|
"jsdoc/empty-tags": "error",
|
||||||
|
"jsdoc/check-property-names": "error",
|
||||||
|
"jsdoc/check-values": "error",
|
||||||
|
"jsdoc/check-param-names": "warn",
|
||||||
|
// "jsdoc/require-param": "warn",
|
||||||
|
"jsdoc/require-param-description": "warn",
|
||||||
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
|
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
|
||||||
"jsx-a11y/media-has-caption": "off",
|
"jsx-a11y/media-has-caption": "off",
|
||||||
"react/display-name": "error",
|
"react/display-name": "error",
|
||||||
@@ -75,6 +82,23 @@ module.exports = {
|
|||||||
"no-console": ["error"],
|
"no-console": ["error"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.tsx",
|
||||||
|
"**/test.ts",
|
||||||
|
"**/test.tsx",
|
||||||
|
"**/test-**",
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"jsdoc/no-types": "off",
|
||||||
|
"jsdoc/empty-tags": "off",
|
||||||
|
"jsdoc/check-property-names": "off",
|
||||||
|
"jsdoc/check-values": "off",
|
||||||
|
"jsdoc/check-param-names": "off",
|
||||||
|
"jsdoc/require-param-description": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check it out
|
- name: Check it out
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: 📥 Download artifact
|
- name: 📥 Download artifact
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract metadata (tags, labels) for Docker
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: ${{ inputs.docker_tags}}
|
tags: ${{ inputs.docker_tags}}
|
||||||
|
|||||||
2
.github/workflows/build-element-call.yaml
vendored
2
.github/workflows/build-element-call.yaml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
|
|||||||
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@@ -7,7 +7,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
|
|||||||
12
.github/workflows/publish-embedded-packages.yaml
vendored
12
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -85,7 +85,7 @@ jobs:
|
|||||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||||
- name: Upload
|
- name: Upload
|
||||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
id-token: write # Allow npm to authenticate as a trusted publisher
|
id-token: write # Allow npm to authenticate as a trusted publisher
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: 📥 Download built element-call artifact
|
- name: 📥 Download built element-call artifact
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
@@ -142,7 +142,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: 📥 Download built element-call artifact
|
- name: 📥 Download built element-call artifact
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||||
@@ -197,7 +197,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
path: element-call
|
path: element-call
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ jobs:
|
|||||||
path: element-call/embedded/ios/Sources/dist
|
path: element-call/embedded/ios/Sources/dist
|
||||||
|
|
||||||
- name: Checkout element-call-swift
|
- name: Checkout element-call-swift
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
with:
|
with:
|
||||||
repository: element-hq/element-call-swift
|
repository: element-hq/element-call-swift
|
||||||
path: element-call-swift
|
path: element-call-swift
|
||||||
@@ -262,7 +262,7 @@ jobs:
|
|||||||
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
|
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
|
||||||
- name: Add release notes
|
- name: Add release notes
|
||||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||||
with:
|
with:
|
||||||
append_body: true
|
append_body: true
|
||||||
body: |
|
body: |
|
||||||
|
|||||||
5
.github/workflows/publish.yaml
vendored
5
.github/workflows/publish.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Create Checksum
|
- name: Create Checksum
|
||||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
${{ env.FILENAME_PREFIX }}.tar.gz
|
${{ env.FILENAME_PREFIX }}.tar.gz
|
||||||
@@ -61,6 +61,7 @@ jobs:
|
|||||||
docker_tags: |
|
docker_tags: |
|
||||||
type=sha,format=short,event=branch
|
type=sha,format=short,event=branch
|
||||||
type=raw,value=${{ github.event.release.tag_name }}
|
type=raw,value=${{ github.event.release.tag_name }}
|
||||||
|
type=raw,value=latest
|
||||||
# Like before, using ${{ env.VERSION }} above doesn't work
|
# Like before, using ${{ env.VERSION }} above doesn't work
|
||||||
add_docker_release_note:
|
add_docker_release_note:
|
||||||
needs: publish_docker
|
needs: publish_docker
|
||||||
@@ -68,7 +69,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Add release note
|
- name: Add release note
|
||||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||||
with:
|
with:
|
||||||
append_body: true
|
append_body: true
|
||||||
body: |
|
body: |
|
||||||
|
|||||||
4
.github/workflows/test.yaml
vendored
4
.github/workflows/test.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- name: Yarn cache
|
- name: Yarn cache
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
|||||||
4
.github/workflows/translations-download.yaml
vendored
4
.github/workflows/translations-download.yaml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable
|
run: corepack enable
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
id: cpr
|
id: cpr
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||||
branch: actions/localazy-download
|
branch: actions/localazy-download
|
||||||
|
|||||||
2
.github/workflows/translations-upload.yaml
vendored
2
.github/workflows/translations-upload.yaml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout the code
|
- name: Checkout the code
|
||||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ coverage:
|
|||||||
informational: true
|
informational: true
|
||||||
patch:
|
patch:
|
||||||
default:
|
default:
|
||||||
# Encourage (but don't enforce) 80% coverage on all lines that a PR
|
# Enforce 80% coverage on all lines that a PR
|
||||||
# touches
|
# touches
|
||||||
target: 80%
|
target: 80%
|
||||||
informational: true
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ services:
|
|||||||
- ecbackend
|
- ecbackend
|
||||||
|
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:v1.9.4
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
hostname: livekit-sfu
|
hostname: livekit-sfu
|
||||||
command: --dev --config /etc/livekit.yaml
|
command: --dev --config /etc/livekit.yaml
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
- ecbackend
|
- ecbackend
|
||||||
|
|
||||||
livekit-1:
|
livekit-1:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:v1.9.4
|
||||||
pull_policy: always
|
pull_policy: always
|
||||||
hostname: livekit-sfu-1
|
hostname: livekit-sfu-1
|
||||||
command: --dev --config /etc/livekit.yaml
|
command: --dev --config /etc/livekit.yaml
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||||
|
|
||||||
[versions]
|
[versions]
|
||||||
android_gradle_plugin = "8.13.0"
|
android_gradle_plugin = "8.13.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" }
|
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.35.0" }
|
||||||
2
knip.ts
2
knip.ts
@@ -9,7 +9,7 @@ import { type KnipConfig } from "knip";
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
vite: {
|
vite: {
|
||||||
config: ["vite.config.ts", "vite-embedded.config.ts"],
|
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
|
||||||
},
|
},
|
||||||
entry: ["src/main.tsx", "i18next-parser.config.ts"],
|
entry: ["src/main.tsx", "i18next-parser.config.ts"],
|
||||||
ignoreBinaries: [
|
ignoreBinaries: [
|
||||||
|
|||||||
@@ -108,11 +108,14 @@
|
|||||||
"connection_lost_description": "You were disconnected from the call.",
|
"connection_lost_description": "You were disconnected from the call.",
|
||||||
"e2ee_unsupported": "Incompatible browser",
|
"e2ee_unsupported": "Incompatible browser",
|
||||||
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
|
||||||
|
"failed_to_start_livekit": "Failed to start Livekit connection",
|
||||||
"generic": "Something went wrong",
|
"generic": "Something went wrong",
|
||||||
"generic_description": "Submitting debug logs will help us track down the problem.",
|
"generic_description": "Submitting debug logs will help us track down the problem.",
|
||||||
"insufficient_capacity": "Insufficient capacity",
|
"insufficient_capacity": "Insufficient capacity",
|
||||||
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
|
||||||
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||||
|
"membership_manager": "Membership Manager Error",
|
||||||
|
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.",
|
||||||
"open_elsewhere": "Opened in another tab",
|
"open_elsewhere": "Opened in another tab",
|
||||||
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
|
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
|
||||||
"room_creation_restricted": "Failed to create call",
|
"room_creation_restricted": "Failed to create call",
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -13,6 +13,8 @@
|
|||||||
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
||||||
"build:embedded:production": "yarn build:embedded",
|
"build:embedded:production": "yarn build:embedded",
|
||||||
"build:embedded:development": "yarn build:embedded --mode development",
|
"build:embedded:development": "yarn build:embedded --mode development",
|
||||||
|
"build:sdk": "yarn build:full --config vite-sdk.config.js",
|
||||||
|
"build:sdk:development": "yarn build:sdk --mode development",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"prettier:check": "prettier -c .",
|
"prettier:check": "prettier -c .",
|
||||||
"prettier:format": "prettier -w .",
|
"prettier:format": "prettier -w .",
|
||||||
@@ -47,7 +49,14 @@
|
|||||||
"@livekit/protocol": "^1.42.2",
|
"@livekit/protocol": "^1.42.2",
|
||||||
"@livekit/track-processors": "^0.5.5",
|
"@livekit/track-processors": "^0.5.5",
|
||||||
"@mediapipe/tasks-vision": "^0.10.18",
|
"@mediapipe/tasks-vision": "^0.10.18",
|
||||||
"@playwright/test": "^1.56.1",
|
"@opentelemetry/api": "^1.4.0",
|
||||||
|
"@opentelemetry/core": "^2.0.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
|
||||||
|
"@opentelemetry/resources": "^2.0.0",
|
||||||
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||||
|
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||||
|
"@opentelemetry/semantic-conventions": "^1.25.1",
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
"@radix-ui/react-dialog": "^1.0.4",
|
"@radix-ui/react-dialog": "^1.0.4",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||||
@@ -86,6 +95,7 @@
|
|||||||
"eslint-config-prettier": "^10.0.0",
|
"eslint-config-prettier": "^10.0.0",
|
||||||
"eslint-plugin-deprecate": "^0.8.2",
|
"eslint-plugin-deprecate": "^0.8.2",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-import": "^2.26.0",
|
||||||
|
"eslint-plugin-jsdoc": "^61.5.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.5.1",
|
"eslint-plugin-jsx-a11y": "^6.5.1",
|
||||||
"eslint-plugin-matrix-org": "2.1.0",
|
"eslint-plugin-matrix-org": "2.1.0",
|
||||||
"eslint-plugin-react": "^7.29.4",
|
"eslint-plugin-react": "^7.29.4",
|
||||||
@@ -102,8 +112,9 @@
|
|||||||
"livekit-client": "^2.13.0",
|
"livekit-client": "^2.13.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"loglevel": "^1.9.1",
|
"loglevel": "^1.9.1",
|
||||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=toger5/sticky-events&commit=e7f5bec51b6f70501a025b79fe5021c933385b21",
|
"matrix-js-sdk": "matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3",
|
||||||
"matrix-widget-api": "^1.13.0",
|
"matrix-widget-api": "^1.14.0",
|
||||||
|
"node-stdlib-browser": "^1.3.1",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
"observable-hooks": "^4.2.3",
|
"observable-hooks": "^4.2.3",
|
||||||
"pako": "^2.0.4",
|
"pako": "^2.0.4",
|
||||||
@@ -126,6 +137,7 @@
|
|||||||
"vite": "^7.0.0",
|
"vite": "^7.0.0",
|
||||||
"vite-plugin-generate-file": "^0.3.0",
|
"vite-plugin-generate-file": "^0.3.0",
|
||||||
"vite-plugin-html": "^3.2.2",
|
"vite-plugin-html": "^3.2.2",
|
||||||
|
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||||
"vite-plugin-svgr": "^4.0.0",
|
"vite-plugin-svgr": "^4.0.0",
|
||||||
"vitest": "^3.0.0",
|
"vitest": "^3.0.0",
|
||||||
"vitest-axe": "^1.0.0-pre.3"
|
"vitest-axe": "^1.0.0-pre.3"
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "chromium",
|
name: "chromium",
|
||||||
|
testIgnore: "**/mobile/**",
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
permissions: [
|
permissions: [
|
||||||
@@ -56,9 +57,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
name: "firefox",
|
name: "firefox",
|
||||||
|
testIgnore: "**/mobile/**",
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Firefox"],
|
...devices["Desktop Firefox"],
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
@@ -70,6 +71,27 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "mobile",
|
||||||
|
testMatch: "**/mobile/**",
|
||||||
|
use: {
|
||||||
|
...devices["Pixel 7"],
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
permissions: [
|
||||||
|
"clipboard-write",
|
||||||
|
"clipboard-read",
|
||||||
|
"microphone",
|
||||||
|
"camera",
|
||||||
|
],
|
||||||
|
launchOptions: {
|
||||||
|
args: [
|
||||||
|
"--use-fake-ui-for-media-stream",
|
||||||
|
"--use-fake-device-for-media-stream",
|
||||||
|
"--mute-audio",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling
|
// No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling
|
||||||
// clear http to the homeserver
|
// clear http to the homeserver
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { createJTWToken } from "./fixtures/jwt-token";
|
||||||
|
|
||||||
test("Should show error screen if fails to get JWT token", async ({ page }) => {
|
test("Should show error screen if fails to get JWT token", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
@@ -75,7 +77,12 @@ test("Should automatically retry non fatal JWT errors", async ({
|
|||||||
|
|
||||||
test("Should show error screen if call creation is restricted", async ({
|
test("Should show error screen if call creation is restricted", async ({
|
||||||
page,
|
page,
|
||||||
|
browserName,
|
||||||
}) => {
|
}) => {
|
||||||
|
test.skip(
|
||||||
|
browserName === "firefox",
|
||||||
|
"The is test is not working on firefox CI environment.",
|
||||||
|
);
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
// We need the socket connection to fail, but this cannot be done by using the websocket route.
|
// We need the socket connection to fail, but this cannot be done by using the websocket route.
|
||||||
@@ -88,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
|
|||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: "wss://badurltotricktest/livekit/sfu",
|
url: "wss://badurltotricktest/livekit/sfu",
|
||||||
jwt: "FAKE",
|
jwt: createJTWToken("@fake:user", "!fake:room"),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
73
playwright/fixtures/fixture-mobile-create.ts
Normal file
73
playwright/fixtures/fixture-mobile-create.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Browser, type Page, test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
export interface MobileCreateFixtures {
|
||||||
|
asMobile: {
|
||||||
|
creatorPage: Page;
|
||||||
|
inviteLink: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mobileTest = test.extend<MobileCreateFixtures>({
|
||||||
|
asMobile: async ({ browser }, pUse) => {
|
||||||
|
const fixtures = await createCallAndInvite(browser);
|
||||||
|
await pUse({
|
||||||
|
creatorPage: fixtures.page,
|
||||||
|
inviteLink: fixtures.inviteLink,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a call and generate an invite link
|
||||||
|
*/
|
||||||
|
async function createCallAndInvite(
|
||||||
|
browser: Browser,
|
||||||
|
): Promise<{ page: Page; inviteLink: string }> {
|
||||||
|
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||||
|
const creatorPage = await creatorContext.newPage();
|
||||||
|
|
||||||
|
await creatorPage.goto("/");
|
||||||
|
|
||||||
|
// ========
|
||||||
|
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
|
||||||
|
// ========
|
||||||
|
await creatorPage.getByTestId("home_callName").click();
|
||||||
|
await creatorPage.getByTestId("home_callName").fill("Welcome");
|
||||||
|
await creatorPage.getByTestId("home_displayName").click();
|
||||||
|
await creatorPage.getByTestId("home_displayName").fill("Inviter");
|
||||||
|
await creatorPage.getByTestId("home_go").click();
|
||||||
|
await expect(creatorPage.locator("video")).toBeVisible();
|
||||||
|
|
||||||
|
await creatorPage
|
||||||
|
.getByRole("button", { name: "Continue in browser" })
|
||||||
|
.click();
|
||||||
|
// join
|
||||||
|
await creatorPage.getByTestId("lobby_joinCall").click();
|
||||||
|
|
||||||
|
// Get the invite link
|
||||||
|
await creatorPage.getByRole("button", { name: "Invite" }).click();
|
||||||
|
await expect(
|
||||||
|
creatorPage.getByRole("heading", { name: "Invite to this call" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||||
|
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||||
|
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||||
|
await creatorPage.getByTestId("modal_inviteLink").click();
|
||||||
|
|
||||||
|
const inviteLink = (await creatorPage.evaluate(
|
||||||
|
"navigator.clipboard.readText()",
|
||||||
|
)) as string;
|
||||||
|
expect(inviteLink).toContain("room/#/");
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: creatorPage,
|
||||||
|
inviteLink,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
playwright/fixtures/jwt-token.ts
Normal file
22
playwright/fixtures/jwt-token.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
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 function createJTWToken(sub: string, room: string): string {
|
||||||
|
return [
|
||||||
|
{}, // header
|
||||||
|
{
|
||||||
|
// payload
|
||||||
|
sub,
|
||||||
|
video: {
|
||||||
|
room,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{}, // signature
|
||||||
|
]
|
||||||
|
.map((d) => global.btoa(JSON.stringify(d)))
|
||||||
|
.join(".");
|
||||||
|
}
|
||||||
@@ -67,7 +67,6 @@ const CONFIG_JSON = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
|
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
|
||||||
* @param page
|
|
||||||
*/
|
*/
|
||||||
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
|
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
|
||||||
? async (page: Page): Promise<void> => {
|
? async (page: Page): Promise<void> => {
|
||||||
@@ -111,19 +110,27 @@ async function registerUser(
|
|||||||
await page.getByRole("textbox", { name: "Confirm password" }).click();
|
await page.getByRole("textbox", { name: "Confirm password" }).click();
|
||||||
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
|
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
|
||||||
await page.getByRole("button", { name: "Register" }).click();
|
await page.getByRole("button", { name: "Register" }).click();
|
||||||
const continueButton = page.getByRole("button", { name: "Continue" });
|
|
||||||
try {
|
|
||||||
await expect(continueButton).toBeVisible({ timeout: 5000 });
|
|
||||||
await page
|
|
||||||
.getByRole("textbox", { name: "Password", exact: true })
|
|
||||||
.fill(PASSWORD);
|
|
||||||
await continueButton.click();
|
|
||||||
} catch {
|
|
||||||
// continueButton not visible, continue as normal
|
|
||||||
}
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: `Welcome ${username}` }),
|
page.getByRole("heading", { name: `Welcome ${username}` }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
|
const browserUnsupportedToast = page
|
||||||
|
.getByText("Element does not support this browser")
|
||||||
|
.locator("..")
|
||||||
|
.locator("..");
|
||||||
|
|
||||||
|
// Dismiss incompatible browser toast
|
||||||
|
const dismissButton = browserUnsupportedToast.getByRole("button", {
|
||||||
|
name: "Dismiss",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(dismissButton).toBeVisible({ timeout: 700 });
|
||||||
|
await dismissButton.click();
|
||||||
|
} catch {
|
||||||
|
// dismissButton not visible, continue as normal
|
||||||
|
}
|
||||||
|
|
||||||
await setDevToolElementCallDevUrl(page);
|
await setDevToolElementCallDevUrl(page);
|
||||||
|
|
||||||
const clientHandle = await page.evaluateHandle(() =>
|
const clientHandle = await page.evaluateHandle(() =>
|
||||||
|
|||||||
115
playwright/mobile/create-call-mobile.spec.ts
Normal file
115
playwright/mobile/create-call-mobile.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { mobileTest } from "../fixtures/fixture-mobile-create.ts";
|
||||||
|
|
||||||
|
test("@mobile Start a new call then leave and show the feedback screen", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
await page.getByTestId("home_callName").click();
|
||||||
|
await page.getByTestId("home_callName").fill("HelloCall");
|
||||||
|
await page.getByTestId("home_displayName").click();
|
||||||
|
await page.getByTestId("home_displayName").fill("John Doe");
|
||||||
|
await page.getByTestId("home_go").click();
|
||||||
|
|
||||||
|
// await page.pause();
|
||||||
|
await expect(page.locator("video")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Continue in browser" }).click();
|
||||||
|
// Join the call
|
||||||
|
await page.getByTestId("lobby_joinCall").click();
|
||||||
|
|
||||||
|
// Ensure that the call is connected
|
||||||
|
await page
|
||||||
|
.locator("div")
|
||||||
|
.filter({ hasText: /^HelloCall$/ })
|
||||||
|
.click();
|
||||||
|
// Check the number of participants
|
||||||
|
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
|
||||||
|
// The tooltip with the name should be visible
|
||||||
|
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
|
||||||
|
|
||||||
|
// leave the call
|
||||||
|
await page.getByTestId("incall_leave").click();
|
||||||
|
await expect(page.getByRole("heading")).toContainText(
|
||||||
|
"John Doe, your call has ended. How did it go?",
|
||||||
|
);
|
||||||
|
await expect(page.getByRole("main")).toContainText(
|
||||||
|
"Why not finish by setting up a password to keep your account?",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("link", { name: "Not now, return to home screen" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
mobileTest(
|
||||||
|
"Test earpiece overlay in controlledAudioDevices mode",
|
||||||
|
async ({ asMobile, browser }) => {
|
||||||
|
test.slow(); // Triples the timeout
|
||||||
|
const { creatorPage, inviteLink } = asMobile;
|
||||||
|
|
||||||
|
// ========
|
||||||
|
// ACT: The other user use the invite link to join the call as a guest
|
||||||
|
// ========
|
||||||
|
const guestInviteeContext = await browser.newContext({
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
const guestPage = await guestInviteeContext.newPage();
|
||||||
|
await guestPage.goto(inviteLink + "&controlledAudioDevices=true");
|
||||||
|
|
||||||
|
await guestPage
|
||||||
|
.getByRole("button", { name: "Continue in browser" })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
|
||||||
|
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
|
||||||
|
await guestPage.getByTestId("joincall_joincall").click();
|
||||||
|
await guestPage.getByTestId("lobby_joinCall").click();
|
||||||
|
|
||||||
|
// ========
|
||||||
|
// ASSERT: check that there are two members in the call
|
||||||
|
// ========
|
||||||
|
|
||||||
|
// There should be two participants now
|
||||||
|
await expect(
|
||||||
|
guestPage.getByTestId("roomHeader_participants_count"),
|
||||||
|
).toContainText("2");
|
||||||
|
expect(await guestPage.getByTestId("videoTile").count()).toBe(2);
|
||||||
|
|
||||||
|
// Same in creator page
|
||||||
|
await expect(
|
||||||
|
creatorPage.getByTestId("roomHeader_participants_count"),
|
||||||
|
).toContainText("2");
|
||||||
|
expect(await creatorPage.getByTestId("videoTile").count()).toBe(2);
|
||||||
|
|
||||||
|
// TEST: control audio devices from the invitee page
|
||||||
|
|
||||||
|
await guestPage.evaluate(() => {
|
||||||
|
window.controls.setAvailableAudioDevices([
|
||||||
|
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||||
|
{ id: "earpiece", name: "Handset", isEarpiece: true },
|
||||||
|
{ id: "headphones", name: "Headphones" },
|
||||||
|
]);
|
||||||
|
window.controls.setAudioDevice("earpiece");
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
guestPage.getByRole("heading", { name: "Handset Mode" }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
guestPage.getByRole("button", { name: "Back to Speaker Mode" }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Should auto-mute the video when earpiece is selected
|
||||||
|
await expect(guestPage.getByTestId("incall_videomute")).toBeDisabled();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -68,11 +68,6 @@ test("When creator left, avoid reconnect to the same SFU", async ({
|
|||||||
reducedMotion: "reduce",
|
reducedMotion: "reduce",
|
||||||
});
|
});
|
||||||
const guestCPage = await guestC.newPage();
|
const guestCPage = await guestC.newPage();
|
||||||
let sfuGetCallCount = 0;
|
|
||||||
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
|
|
||||||
sfuGetCallCount++;
|
|
||||||
await route.continue();
|
|
||||||
});
|
|
||||||
// Track WebSocket connections
|
// Track WebSocket connections
|
||||||
let wsConnectionCount = 0;
|
let wsConnectionCount = 0;
|
||||||
await guestCPage.routeWebSocket("**", (ws) => {
|
await guestCPage.routeWebSocket("**", (ws) => {
|
||||||
@@ -100,5 +95,4 @@ test("When creator left, avoid reconnect to the same SFU", async ({
|
|||||||
// https://github.com/element-hq/element-call/issues/3344
|
// https://github.com/element-hq/element-call/issues/3344
|
||||||
// The app used to request a new jwt token then to reconnect to the SFU
|
// The app used to request a new jwt token then to reconnect to the SFU
|
||||||
expect(wsConnectionCount).toBe(1);
|
expect(wsConnectionCount).toBe(1);
|
||||||
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
|
|
||||||
});
|
});
|
||||||
|
|||||||
35
sdk/README.md
Normal file
35
sdk/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# SDK mode
|
||||||
|
|
||||||
|
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||||
|
|
||||||
|
It allows to use matrixRTC in combination with livekit without relying on element call.
|
||||||
|
|
||||||
|
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
|
||||||
|
|
||||||
|
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver ellowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
|
||||||
|
|
||||||
|
## Widgets
|
||||||
|
|
||||||
|
The sdk mode is particularly interesting to be used in widgets where you do not need to pay attention to matrix login/cs api ...
|
||||||
|
To create a widget see the example index.html file in this folder. And add it to EW via:
|
||||||
|
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
|
||||||
|
|
||||||
|
### url parameters
|
||||||
|
|
||||||
|
```
|
||||||
|
widgetId = $matrix_widget_id
|
||||||
|
perParticipantE2EE = true
|
||||||
|
userId = $matrix_user_id
|
||||||
|
deviceId = $org.matrix.msc3819.matrix_device_id
|
||||||
|
baseUrl = $org.matrix.msc4039.matrix_base_url
|
||||||
|
```
|
||||||
|
|
||||||
|
`parentUrl = // will be inserted automatically`
|
||||||
|
|
||||||
|
Full template use as `<widgetUrl>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
|
||||||
|
```
|
||||||
|
|
||||||
|
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
|
||||||
55
sdk/helper.ts
Normal file
55
sdk/helper.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This file contains helper functions and types for the MatrixRTC SDK.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { scan } from "rxjs";
|
||||||
|
|
||||||
|
import { widget as _widget } from "../src/widget";
|
||||||
|
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
|
||||||
|
|
||||||
|
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
|
||||||
|
|
||||||
|
if (!_widget) throw Error("No widget. This webapp can only start as a widget");
|
||||||
|
export const widget = _widget;
|
||||||
|
|
||||||
|
export const tryMakeSticky = (): void => {
|
||||||
|
logger.info("try making sticky MatrixRTCSdk");
|
||||||
|
void widget.api
|
||||||
|
.setAlwaysOnScreen(true)
|
||||||
|
.then(() => {
|
||||||
|
logger.info("sticky MatrixRTCSdk");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error("failed to make sticky MatrixRTCSdk", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const TEXT_LK_TOPIC = "matrixRTC";
|
||||||
|
/**
|
||||||
|
* simple helper operator to combine the last emitted and the current emitted value of a rxjs observable
|
||||||
|
*
|
||||||
|
* I think there should be a builtin for this but i did not find it...
|
||||||
|
*/
|
||||||
|
export const currentAndPrev = scan<
|
||||||
|
LivekitRoomItem[],
|
||||||
|
{
|
||||||
|
prev: LivekitRoomItem[];
|
||||||
|
current: LivekitRoomItem[];
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
({ current: lastCurrentVal }, items) => ({
|
||||||
|
prev: lastCurrentVal,
|
||||||
|
current: items,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
prev: [],
|
||||||
|
current: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
87
sdk/index.html
Normal file
87
sdk/index.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Godot MatrixRTC Widget</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<script type="module">
|
||||||
|
// TODO use the url where the matrixrtc-sdk.js file from dist is hosted
|
||||||
|
import { createMatrixRTCSdk } from "http://localhost:8123/matrixrtc-sdk.js";
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.matrixRTCSdk = await createMatrixRTCSdk(
|
||||||
|
"com.github.toger5.godot-game",
|
||||||
|
);
|
||||||
|
console.info("createMatrixRTCSdk was created!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("createMatrixRTCSdk", e);
|
||||||
|
}
|
||||||
|
const sdk = window.matrixRTCSdk;
|
||||||
|
|
||||||
|
// This is the main bridging interface to godot
|
||||||
|
window.matrixRTCSdkGodot = {
|
||||||
|
dataObs: sdk.data$,
|
||||||
|
memberObs: sdk.members$,
|
||||||
|
// join: sdk.join, // lets stick with autojoin for now
|
||||||
|
sendData: sdk.sendData,
|
||||||
|
leave: sdk.leave,
|
||||||
|
connectedObs: sdk.connected$,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info("matrixRTCSdk join ", sdk);
|
||||||
|
const connectionState = sdk.join();
|
||||||
|
console.info("matrixRTCSdk joined");
|
||||||
|
|
||||||
|
const div = document.getElementById("data");
|
||||||
|
div.innerHTML = "<h3>Data:</h3>";
|
||||||
|
|
||||||
|
sdk.data$.subscribe((data) => {
|
||||||
|
const child = document.createElement("p");
|
||||||
|
child.innerHTML = JSON.stringify(data);
|
||||||
|
div.appendChild(child);
|
||||||
|
// TODO forward to godot
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.members$.subscribe((memberObjects) => {
|
||||||
|
// reset div
|
||||||
|
const div = document.getElementById("members");
|
||||||
|
div.innerHTML = "<h3>Members:</h3>";
|
||||||
|
|
||||||
|
// create member list
|
||||||
|
const members = memberObjects.map((member) => member.membership.sender);
|
||||||
|
console.info("members changed", members);
|
||||||
|
for (const m of members) {
|
||||||
|
console.info("member", m);
|
||||||
|
const child = document.createElement("p");
|
||||||
|
child.innerHTML = m;
|
||||||
|
div.appendChild(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sdk.connected$.subscribe((connected) => {
|
||||||
|
console.info("connected changed", connected);
|
||||||
|
const div = document.getElementById("connect_status");
|
||||||
|
div.innerHTML = connected ? "Connected" : "Disconnected";
|
||||||
|
});
|
||||||
|
|
||||||
|
let engine = new Engine($GODOT_CONFIG);
|
||||||
|
engine.startGame();
|
||||||
|
</script>
|
||||||
|
<!--// TODO use it as godot HTML template-->
|
||||||
|
<script src="$GODOT_URL"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="canvas"></canvas>
|
||||||
|
<div
|
||||||
|
id="overlay"
|
||||||
|
style="position: absolute; top: 0; right: 0; background-color: #ffffff10"
|
||||||
|
>
|
||||||
|
<div id="connect_status"></div>
|
||||||
|
<button onclick="window.matrixRTCSdk.leave();">Leave</button>
|
||||||
|
<button onclick="window.matrixRTCSdk.sendData({prop: 'Hello, world!'});">
|
||||||
|
Send Text
|
||||||
|
</button>
|
||||||
|
<div id="members"></div>
|
||||||
|
<div id="data"></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
308
sdk/main.ts
Normal file
308
sdk/main.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
|
||||||
|
* use in widgets.
|
||||||
|
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
|
||||||
|
* can be used to join a rtc session and exchange realtime data.
|
||||||
|
* It takes care of all the tricky bits:
|
||||||
|
* - sending delayed events
|
||||||
|
* - finding the right sfu
|
||||||
|
* - handling the media stream
|
||||||
|
* - sending join/leave state or sticky events
|
||||||
|
* - setting up encryption and scharing keys
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
map,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
shareReplay,
|
||||||
|
Subject,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
} from "rxjs";
|
||||||
|
import {
|
||||||
|
type CallMembership,
|
||||||
|
MatrixRTCSession,
|
||||||
|
MatrixRTCSessionEvent,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import {
|
||||||
|
type Room as LivekitRoom,
|
||||||
|
type TextStreamReader,
|
||||||
|
type LocalParticipant,
|
||||||
|
type RemoteParticipant,
|
||||||
|
} from "livekit-client";
|
||||||
|
|
||||||
|
// TODO how can this get fixed? to just be part of `livekit-client`
|
||||||
|
// Can this be done in the tsconfig.json
|
||||||
|
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||||
|
import { type Behavior, constant } from "../src/state/Behavior";
|
||||||
|
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
|
||||||
|
import { ObservableScope } from "../src/state/ObservableScope";
|
||||||
|
import { getUrlParams } from "../src/UrlParams";
|
||||||
|
import { MuteStates } from "../src/state/MuteStates";
|
||||||
|
import { MediaDevices } from "../src/state/MediaDevices";
|
||||||
|
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||||
|
import {
|
||||||
|
currentAndPrev,
|
||||||
|
logger,
|
||||||
|
TEXT_LK_TOPIC,
|
||||||
|
tryMakeSticky,
|
||||||
|
widget,
|
||||||
|
} from "./helper";
|
||||||
|
import { ElementWidgetActions } from "../src/widget";
|
||||||
|
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||||
|
|
||||||
|
interface MatrixRTCSdk {
|
||||||
|
/**
|
||||||
|
* observe connected$ to track the state.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
join: () => void;
|
||||||
|
/** @throws on leave errors */
|
||||||
|
leave: () => void;
|
||||||
|
data$: Observable<{ sender: string; data: string }>;
|
||||||
|
/**
|
||||||
|
* flattened list of members
|
||||||
|
*/
|
||||||
|
members$: Behavior<
|
||||||
|
{
|
||||||
|
connection: Connection | null;
|
||||||
|
membership: CallMembership;
|
||||||
|
participant: LocalParticipant | RemoteParticipant | null;
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||||
|
connected$: Behavior<boolean>;
|
||||||
|
sendData?: (data: unknown) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMatrixRTCSdk(
|
||||||
|
application: string = "m.call",
|
||||||
|
id: string = "",
|
||||||
|
): Promise<MatrixRTCSdk> {
|
||||||
|
logger.info("Hello");
|
||||||
|
const client = await widget.client;
|
||||||
|
logger.info("client created");
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const { roomId } = getUrlParams();
|
||||||
|
if (roomId === null) throw Error("could not get roomId from url params");
|
||||||
|
|
||||||
|
const room = client.getRoom(roomId);
|
||||||
|
if (room === null) throw Error("could not get room from client");
|
||||||
|
|
||||||
|
const mediaDevices = new MediaDevices(scope);
|
||||||
|
const muteStates = new MuteStates(scope, mediaDevices, constant(true));
|
||||||
|
const slot = { application, id };
|
||||||
|
const rtcSession = new MatrixRTCSession(
|
||||||
|
client,
|
||||||
|
room,
|
||||||
|
MatrixRTCSession.sessionMembershipsForSlot(room, slot),
|
||||||
|
slot,
|
||||||
|
);
|
||||||
|
const callViewModel = createCallViewModel$(
|
||||||
|
scope,
|
||||||
|
rtcSession,
|
||||||
|
room,
|
||||||
|
mediaDevices,
|
||||||
|
muteStates,
|
||||||
|
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
|
||||||
|
of({}),
|
||||||
|
of({}),
|
||||||
|
constant({ supported: false, processor: undefined }),
|
||||||
|
);
|
||||||
|
logger.info("CallViewModelCreated");
|
||||||
|
// create data listener
|
||||||
|
const data$ = new Subject<{ sender: string; data: string }>();
|
||||||
|
|
||||||
|
const lkTextStreamHandlerFunction = async (
|
||||||
|
reader: TextStreamReader,
|
||||||
|
participantInfo: { identity: string },
|
||||||
|
livekitRoom: LivekitRoom,
|
||||||
|
): Promise<void> => {
|
||||||
|
const info = reader.info;
|
||||||
|
logger.info(
|
||||||
|
`Received text stream from ${participantInfo.identity}\n` +
|
||||||
|
` Topic: ${info.topic}\n` +
|
||||||
|
` Timestamp: ${info.timestamp}\n` +
|
||||||
|
` ID: ${info.id}\n` +
|
||||||
|
` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText`
|
||||||
|
);
|
||||||
|
|
||||||
|
const participants = callViewModel.livekitRoomItems$.value.find(
|
||||||
|
(i) => i.livekitRoom === livekitRoom,
|
||||||
|
)?.participants;
|
||||||
|
if (participants && participants.includes(participantInfo.identity)) {
|
||||||
|
const text = await reader.readAll();
|
||||||
|
logger.info(`Received text: ${text}`);
|
||||||
|
data$.next({ sender: participantInfo.identity, data: text });
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
"Received text from unknown participant",
|
||||||
|
participantInfo.identity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const livekitRoomItemsSub = callViewModel.livekitRoomItems$
|
||||||
|
.pipe(
|
||||||
|
tap((beforecurrentAndPrev) => {
|
||||||
|
logger.info(
|
||||||
|
`LiveKit room items updated: ${beforecurrentAndPrev.length}`,
|
||||||
|
beforecurrentAndPrev,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
currentAndPrev,
|
||||||
|
tap((aftercurrentAndPrev) => {
|
||||||
|
logger.info(
|
||||||
|
`LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`,
|
||||||
|
aftercurrentAndPrev,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: ({ prev, current }) => {
|
||||||
|
const prevRooms = prev.map((i) => i.livekitRoom);
|
||||||
|
const currentRooms = current.map((i) => i.livekitRoom);
|
||||||
|
const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r));
|
||||||
|
const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r));
|
||||||
|
addedRooms.forEach((r) => {
|
||||||
|
logger.info(`Registering text stream handler for room `);
|
||||||
|
r.registerTextStreamHandler(
|
||||||
|
TEXT_LK_TOPIC,
|
||||||
|
(reader, participantInfo) =>
|
||||||
|
void lkTextStreamHandlerFunction(reader, participantInfo, r),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
removedRooms.forEach((r) => {
|
||||||
|
logger.info(`Unregistering text stream handler for room `);
|
||||||
|
r.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
complete: () => {
|
||||||
|
logger.info("Livekit room items subscription completed");
|
||||||
|
for (const item of callViewModel.livekitRoomItems$.value) {
|
||||||
|
logger.info("unregistering room item from room", item.url);
|
||||||
|
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// create sendData function
|
||||||
|
const sendFn: Behavior<(data: string) => Promise<TextStreamInfo>> =
|
||||||
|
scope.behavior(
|
||||||
|
callViewModel.localMatrixLivekitMember$.pipe(
|
||||||
|
switchMap((m) => {
|
||||||
|
if (!m)
|
||||||
|
return of((data: string): never => {
|
||||||
|
throw Error("local membership not yet ready.");
|
||||||
|
});
|
||||||
|
return m.participant.value$.pipe(
|
||||||
|
map((p) => {
|
||||||
|
if (p === null) {
|
||||||
|
return (data: string): never => {
|
||||||
|
throw Error("local participant not yet ready to send data.");
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return async (data: string): Promise<TextStreamInfo> =>
|
||||||
|
p.sendText(data, { topic: TEXT_LK_TOPIC });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendData = async (data: unknown): Promise<void> => {
|
||||||
|
const dataString = JSON.stringify(data);
|
||||||
|
logger.info("try sending: ", dataString);
|
||||||
|
try {
|
||||||
|
await Promise.resolve();
|
||||||
|
const info = await sendFn.value(dataString);
|
||||||
|
logger.info(`Sent text with stream ID: ${info.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("failed sending: ", dataString, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// after hangup gets called
|
||||||
|
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
||||||
|
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
||||||
|
const leaveResolver = Promise.withResolvers<void>();
|
||||||
|
logger.info("waiting for RTC leave");
|
||||||
|
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => {
|
||||||
|
logger.info("received RTC join update: ", isJoined);
|
||||||
|
if (!isJoined) leaveResolver.resolve();
|
||||||
|
});
|
||||||
|
await leaveResolver.promise;
|
||||||
|
logger.info("send Unstick");
|
||||||
|
await widget.api
|
||||||
|
.setAlwaysOnScreen(false)
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
"Failed to set call widget `alwaysOnScreen` to false",
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
logger.info("send Close");
|
||||||
|
await widget.api.transport
|
||||||
|
.send(ElementWidgetActions.Close, {})
|
||||||
|
.catch((e) => logger.error("Failed to send close action", e));
|
||||||
|
};
|
||||||
|
|
||||||
|
// schedule close first and then leave (scope.end)
|
||||||
|
void scheduleWidgetCloseOnLeave();
|
||||||
|
|
||||||
|
// actual hangup (ending scope will send the leave event.. its kinda odd. since you might end up closing the widget too fast)
|
||||||
|
scope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("createMatrixRTCSdk done");
|
||||||
|
|
||||||
|
return {
|
||||||
|
join: (): void => {
|
||||||
|
// first lets try making the widget sticky
|
||||||
|
tryMakeSticky();
|
||||||
|
callViewModel.join();
|
||||||
|
},
|
||||||
|
leave: (): void => {
|
||||||
|
callViewModel.hangup();
|
||||||
|
leaveSubs.unsubscribe();
|
||||||
|
livekitRoomItemsSub.unsubscribe();
|
||||||
|
},
|
||||||
|
data$,
|
||||||
|
connected$: callViewModel.connected$,
|
||||||
|
members$: scope.behavior(
|
||||||
|
callViewModel.matrixLivekitMembers$.pipe(
|
||||||
|
switchMap((members) => {
|
||||||
|
const listOfMemberObservables = members.map((member) =>
|
||||||
|
combineLatest([
|
||||||
|
member.connection$,
|
||||||
|
member.membership$,
|
||||||
|
member.participant.value$,
|
||||||
|
]).pipe(
|
||||||
|
map(([connection, membership, participant]) => ({
|
||||||
|
connection,
|
||||||
|
membership,
|
||||||
|
participant,
|
||||||
|
})),
|
||||||
|
// using shareReplay instead of a Behavior here because the behavior would need
|
||||||
|
// a tricky scope.end() setup.
|
||||||
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return combineLatest(listOfMemberObservables);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
sendData,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -332,6 +332,42 @@ describe("UrlParams", () => {
|
|||||||
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("noiseSuppression", () => {
|
||||||
|
it("defaults to true", () => {
|
||||||
|
expect(computeUrlParams().noiseSuppression).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is parsed", () => {
|
||||||
|
expect(
|
||||||
|
computeUrlParams("?intent=start_call&noiseSuppression=true")
|
||||||
|
.noiseSuppression,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
computeUrlParams("?intent=start_call&noiseSuppression&bar=foo")
|
||||||
|
.noiseSuppression,
|
||||||
|
).toBe(true);
|
||||||
|
expect(computeUrlParams("?noiseSuppression=false").noiseSuppression).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("echoCancellation", () => {
|
||||||
|
it("defaults to true", () => {
|
||||||
|
expect(computeUrlParams().echoCancellation).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is parsed", () => {
|
||||||
|
expect(computeUrlParams("?echoCancellation=true").echoCancellation).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(computeUrlParams("?echoCancellation=false").echoCancellation).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("header", () => {
|
describe("header", () => {
|
||||||
it("uses header if provided", () => {
|
it("uses header if provided", () => {
|
||||||
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
||||||
|
|||||||
@@ -233,6 +233,17 @@ export interface UrlConfiguration {
|
|||||||
*/
|
*/
|
||||||
waitForCallPickup: boolean;
|
waitForCallPickup: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to enable echo cancellation for audio capture.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
echoCancellation?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to enable noise suppression for audio capture.
|
||||||
|
* Defaults to true.
|
||||||
|
*/
|
||||||
|
noiseSuppression?: boolean;
|
||||||
|
|
||||||
callIntent?: RTCCallIntent;
|
callIntent?: RTCCallIntent;
|
||||||
}
|
}
|
||||||
interface IntentAndPlatformDerivedConfiguration {
|
interface IntentAndPlatformDerivedConfiguration {
|
||||||
@@ -525,6 +536,8 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
|||||||
]),
|
]),
|
||||||
waitForCallPickup: parser.getFlag("waitForCallPickup"),
|
waitForCallPickup: parser.getFlag("waitForCallPickup"),
|
||||||
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
|
||||||
|
noiseSuppression: parser.getFlagParam("noiseSuppression", true),
|
||||||
|
echoCancellation: parser.getFlagParam("echoCancellation", true),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Log the final configuration for debugging purposes.
|
// Log the final configuration for debugging purposes.
|
||||||
|
|||||||
@@ -247,9 +247,8 @@ export class PosthogAnalytics {
|
|||||||
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
|
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
|
||||||
// until the next time account data is refreshed and this function is called (most likely on next
|
// until the next time account data is refreshed and this function is called (most likely on next
|
||||||
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
|
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
|
||||||
const accountDataAnalyticsId = analyticsIdGenerator();
|
analyticsID = analyticsIdGenerator();
|
||||||
await this.setAccountAnalyticsId(accountDataAnalyticsId);
|
await this.setAccountAnalyticsId(analyticsID);
|
||||||
analyticsID = await this.hashedEcAnalyticsId(accountDataAnalyticsId);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// The above could fail due to network requests, but not essential to starting the application,
|
// The above could fail due to network requests, but not essential to starting the application,
|
||||||
@@ -270,37 +269,14 @@ export class PosthogAnalytics {
|
|||||||
|
|
||||||
private async getAnalyticsId(): Promise<string | null> {
|
private async getAnalyticsId(): Promise<string | null> {
|
||||||
const client: MatrixClient = window.matrixclient;
|
const client: MatrixClient = window.matrixclient;
|
||||||
let accountAnalyticsId: string | null;
|
|
||||||
if (widget) {
|
if (widget) {
|
||||||
accountAnalyticsId = getUrlParams().posthogUserId;
|
return getUrlParams().posthogUserId;
|
||||||
} else {
|
} else {
|
||||||
const accountData = await client.getAccountDataFromServer(
|
const accountData = await client.getAccountDataFromServer(
|
||||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||||
);
|
);
|
||||||
accountAnalyticsId = accountData?.id ?? null;
|
return accountData?.id ?? null;
|
||||||
}
|
}
|
||||||
if (accountAnalyticsId) {
|
|
||||||
// we dont just use the element web analytics ID because that would allow to associate
|
|
||||||
// users between the two posthog instances. By using a hash from the username and the element web analytics id
|
|
||||||
// it is not possible to conclude the element web posthog user id from the element call user id and vice versa.
|
|
||||||
return await this.hashedEcAnalyticsId(accountAnalyticsId);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async hashedEcAnalyticsId(
|
|
||||||
accountAnalyticsId: string,
|
|
||||||
): Promise<string> {
|
|
||||||
const client: MatrixClient = window.matrixclient;
|
|
||||||
const posthogIdMaterial = "ec" + accountAnalyticsId + client.getUserId();
|
|
||||||
const bufferForPosthogId = await crypto.subtle.digest(
|
|
||||||
"sha-256",
|
|
||||||
new TextEncoder().encode(posthogIdMaterial),
|
|
||||||
);
|
|
||||||
const view = new Int32Array(bufferForPosthogId);
|
|
||||||
return Array.from(view)
|
|
||||||
.map((b) => Math.abs(b).toString(16).padStart(2, "0"))
|
|
||||||
.join("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
|
|||||||
`room-shared-key-${roomId}`;
|
`room-shared-key-${roomId}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`.
|
* An up-to-date shared key for the room. Either from local storage or the value from `setInitialValue`.
|
||||||
* @param roomId
|
* @param roomId The room ID we want the shared key for.
|
||||||
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
|
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
|
||||||
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
|
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -166,7 +166,11 @@ interface StereoPanAudioTrackProps {
|
|||||||
* It main purpose is to remount the AudioTrack component when switching from
|
* It main purpose is to remount the AudioTrack component when switching from
|
||||||
* audioContext to normal audio playback.
|
* audioContext to normal audio playback.
|
||||||
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
|
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
|
||||||
* @param param0
|
* @param props The component props
|
||||||
|
* @param props.trackRef The track reference
|
||||||
|
* @param props.muted If the track should be muted
|
||||||
|
* @param props.audioContext The audio context to use
|
||||||
|
* @param props.audioNodes The audio nodes to use
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
function AudioTrackWithAudioNodes({
|
function AudioTrackWithAudioNodes({
|
||||||
|
|||||||
112
src/livekit/openIDSFU.test.ts
Normal file
112
src/livekit/openIDSFU.test.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type MockedObject,
|
||||||
|
vitest,
|
||||||
|
} from "vitest";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
|
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
|
||||||
|
import { testJWTToken } from "../utils/test-fixtures";
|
||||||
|
|
||||||
|
const sfuUrl = "https://sfu.example.org";
|
||||||
|
|
||||||
|
describe("getSFUConfigWithOpenID", () => {
|
||||||
|
let matrixClient: MockedObject<OpenIDClientParts>;
|
||||||
|
beforeEach(() => {
|
||||||
|
matrixClient = {
|
||||||
|
getOpenIdToken: vitest.fn(),
|
||||||
|
getDeviceId: vitest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vitest.clearAllMocks();
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
it("should handle fetching a token", async () => {
|
||||||
|
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: sfuUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const config = await getSFUConfigWithOpenID(
|
||||||
|
matrixClient,
|
||||||
|
"https://sfu.example.org",
|
||||||
|
"!example_room_id",
|
||||||
|
);
|
||||||
|
expect(config).toEqual({
|
||||||
|
jwt: testJWTToken,
|
||||||
|
url: sfuUrl,
|
||||||
|
livekitIdentity: "@me:example.org:ABCDEF",
|
||||||
|
livekitAlias: "!example_room_id",
|
||||||
|
});
|
||||||
|
void (await fetchMock.flush());
|
||||||
|
});
|
||||||
|
it("should fail if the SFU errors", async () => {
|
||||||
|
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||||
|
return {
|
||||||
|
status: 500,
|
||||||
|
body: { error: "Test failure" },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await getSFUConfigWithOpenID(
|
||||||
|
matrixClient,
|
||||||
|
"https://sfu.example.org",
|
||||||
|
"!example_room_id",
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
expect(((ex as Error).cause as Error).message).toEqual(
|
||||||
|
"SFU Config fetch failed with status code 500",
|
||||||
|
);
|
||||||
|
void (await fetchMock.flush());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect.fail("Expected test to throw;");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retry fetching the openid token", async () => {
|
||||||
|
let count = 0;
|
||||||
|
matrixClient.getOpenIdToken.mockImplementation(async () => {
|
||||||
|
count++;
|
||||||
|
if (count < 2) {
|
||||||
|
throw Error("Test failure");
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
token_type: "Bearer",
|
||||||
|
access_token: "foobar",
|
||||||
|
matrix_server_name: "example.org",
|
||||||
|
expires_in: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
fetchMock.post("https://sfu.example.org/sfu/get", () => {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { url: sfuUrl, jwt: testJWTToken },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const config = await getSFUConfigWithOpenID(
|
||||||
|
matrixClient,
|
||||||
|
"https://sfu.example.org",
|
||||||
|
"!example_room_id",
|
||||||
|
);
|
||||||
|
expect(config).toEqual({
|
||||||
|
jwt: testJWTToken,
|
||||||
|
url: sfuUrl,
|
||||||
|
livekitIdentity: "@me:example.org:ABCDEF",
|
||||||
|
livekitAlias: "!example_room_id",
|
||||||
|
});
|
||||||
|
void (await fetchMock.flush());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,9 +11,47 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { FailToGetOpenIdToken } from "../utils/errors";
|
import { FailToGetOpenIdToken } from "../utils/errors";
|
||||||
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
import { doNetworkOperationWithRetry } from "../utils/matrix";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration and access tokens provided by the SFU on successful authentication.
|
||||||
|
*/
|
||||||
export interface SFUConfig {
|
export interface SFUConfig {
|
||||||
url: string;
|
url: string;
|
||||||
jwt: string;
|
jwt: string;
|
||||||
|
livekitAlias: string;
|
||||||
|
livekitIdentity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoded details from the JWT.
|
||||||
|
*/
|
||||||
|
interface SFUJWTPayload {
|
||||||
|
/**
|
||||||
|
* Expiration time for the JWT.
|
||||||
|
* Note: This value is in seconds since Unix epoch.
|
||||||
|
*/
|
||||||
|
exp: number;
|
||||||
|
/**
|
||||||
|
* Name of the instance which authored the JWT
|
||||||
|
*/
|
||||||
|
iss: string;
|
||||||
|
/**
|
||||||
|
* Time at which the JWT can start to be used.
|
||||||
|
* Note: This value is in seconds since Unix epoch.
|
||||||
|
*/
|
||||||
|
nbf: number;
|
||||||
|
/**
|
||||||
|
* Subject. The Livekit alias in this context.
|
||||||
|
*/
|
||||||
|
sub: string;
|
||||||
|
/**
|
||||||
|
* The set of permissions for the user.
|
||||||
|
*/
|
||||||
|
video: {
|
||||||
|
canPublish: boolean;
|
||||||
|
canSubscribe: boolean;
|
||||||
|
room: string;
|
||||||
|
roomJoin: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bits we need from MatrixClient
|
// The bits we need from MatrixClient
|
||||||
@@ -25,9 +63,9 @@ export type OpenIDClientParts = Pick<
|
|||||||
* Gets a bearer token from the homeserver and then use it to authenticate
|
* Gets a bearer token from the homeserver and then use it to authenticate
|
||||||
* to the matrix RTC backend in order to get acces to the SFU.
|
* to the matrix RTC backend in order to get acces to the SFU.
|
||||||
* It has built-in retry for calls to the homeserver with a backoff policy.
|
* It has built-in retry for calls to the homeserver with a backoff policy.
|
||||||
* @param client
|
* @param client The Matrix client
|
||||||
* @param serviceUrl
|
* @param serviceUrl The URL of the livekit SFU service
|
||||||
* @param matrixRoomId
|
* @param matrixRoomId The Matrix room ID for which to get the SFU config
|
||||||
* @returns Object containing the token information
|
* @returns Object containing the token information
|
||||||
* @throws FailToGetOpenIdToken
|
* @throws FailToGetOpenIdToken
|
||||||
*/
|
*/
|
||||||
@@ -57,7 +95,17 @@ export async function getSFUConfigWithOpenID(
|
|||||||
);
|
);
|
||||||
logger.info(`Got JWT from call's active focus URL.`);
|
logger.info(`Got JWT from call's active focus URL.`);
|
||||||
|
|
||||||
return sfuConfig;
|
// Pull the details from the JWT
|
||||||
|
const [, payloadStr] = sfuConfig.jwt.split(".");
|
||||||
|
// TODO: Prefer Uint8Array.fromBase64 when widely available
|
||||||
|
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
|
||||||
|
return {
|
||||||
|
jwt: sfuConfig.jwt,
|
||||||
|
url: sfuConfig.url,
|
||||||
|
livekitAlias: payload.video.room,
|
||||||
|
// NOTE: Currently unused.
|
||||||
|
livekitIdentity: payload.sub,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLiveKitJWT(
|
async function getLiveKitJWT(
|
||||||
@@ -65,7 +113,7 @@ async function getLiveKitJWT(
|
|||||||
livekitServiceURL: string,
|
livekitServiceURL: string,
|
||||||
roomName: string,
|
roomName: string,
|
||||||
openIDToken: IOpenIDToken,
|
openIDToken: IOpenIDToken,
|
||||||
): Promise<SFUConfig> {
|
): Promise<{ url: string; jwt: string }> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(livekitServiceURL + "/sfu/get", {
|
const res = await fetch(livekitServiceURL + "/sfu/get", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -83,6 +131,6 @@ async function getLiveKitJWT(
|
|||||||
}
|
}
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error("SFU Config fetch failed with exception " + e);
|
throw new Error("SFU Config fetch failed with exception", { cause: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { render, configure } from "@testing-library/react";
|
import { render, configure } from "@testing-library/react";
|
||||||
|
|
||||||
import { RaisedHandIndicator } from "./RaisedHandIndicator";
|
import { RaisedHandIndicator } from "./RaisedHandIndicator";
|
||||||
@@ -15,6 +15,13 @@ configure({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("RaisedHandIndicator", () => {
|
describe("RaisedHandIndicator", () => {
|
||||||
|
const fixedTime = new Date("2025-01-01T12:00:00.000Z");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(fixedTime);
|
||||||
|
});
|
||||||
|
|
||||||
test("renders nothing when no hand has been raised", () => {
|
test("renders nothing when no hand has been raised", () => {
|
||||||
const { container } = render(<RaisedHandIndicator />);
|
const { container } = render(<RaisedHandIndicator />);
|
||||||
expect(container.firstChild).toBeNull();
|
expect(container.firstChild).toBeNull();
|
||||||
|
|||||||
@@ -135,10 +135,10 @@ export class ReactionsReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetchest any hand wave reactions by the given sender on the given
|
* Fetches any hand wave reactions by the given sender on the given
|
||||||
* membership event.
|
* membership event.
|
||||||
* @param membershipEventId
|
* @param membershipEventId - The user membership event id.
|
||||||
* @param expectedSender
|
* @param expectedSender - The expected sender of the reaction.
|
||||||
* @returns A MatrixEvent if one was found.
|
* @returns A MatrixEvent if one was found.
|
||||||
*/
|
*/
|
||||||
private getLastReactionEvent(
|
private getLastReactionEvent(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ exports[`RaisedHandIndicator > renders a smaller indicator when miniature is spe
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
00:01
|
00:00
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -35,7 +35,7 @@ exports[`RaisedHandIndicator > renders an indicator when a hand has been raised
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
00:01
|
00:00
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -55,7 +55,7 @@ exports[`RaisedHandIndicator > renders an indicator when a hand has been raised
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
01:01
|
01:00
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -7,34 +7,17 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: var(--cpd-space-2x);
|
gap: var(--cpd-space-2x);
|
||||||
}
|
transition: opacity 200ms;
|
||||||
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay[data-show="true"] {
|
.overlay[data-show="true"] {
|
||||||
animation: fade-in 200ms;
|
opacity: 1;
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-out {
|
|
||||||
from {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 0;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay[data-show="false"] {
|
.overlay[data-show="false"] {
|
||||||
animation: fade-out 130ms forwards;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transition-duration: 130ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay::before {
|
.overlay::before {
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
}, [rtcSession]);
|
}, [rtcSession]);
|
||||||
|
|
||||||
// TODO move this into the callViewModel LocalMembership.ts
|
// TODO move this into the callViewModel LocalMembership.ts
|
||||||
|
// We might actually not need this at all. Since we get into fatalError on those errors already?
|
||||||
useTypedEventEmitter(
|
useTypedEventEmitter(
|
||||||
rtcSession,
|
rtcSession,
|
||||||
MatrixRTCSessionEvent.MembershipManagerError,
|
MatrixRTCSessionEvent.MembershipManagerError,
|
||||||
@@ -313,6 +314,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// TODO split this into leave and onDisconnect
|
||||||
const onLeft = useCallback(
|
const onLeft = useCallback(
|
||||||
(
|
(
|
||||||
reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",
|
reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { BehaviorSubject, map } from "rxjs";
|
import { BehaviorSubject, map } from "rxjs";
|
||||||
import { useObservable } from "observable-hooks";
|
import { useObservable } from "observable-hooks";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
VoiceCallSolidIcon,
|
VoiceCallSolidIcon,
|
||||||
VolumeOnSolidIcon,
|
VolumeOnSolidIcon,
|
||||||
@@ -87,6 +87,7 @@ import { ReactionsOverlay } from "./ReactionsOverlay";
|
|||||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||||
import {
|
import {
|
||||||
debugTileLayout as debugTileLayoutSetting,
|
debugTileLayout as debugTileLayoutSetting,
|
||||||
|
matrixRTCMode as matrixRTCModeSetting,
|
||||||
useSetting,
|
useSetting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import { ReactionsReader } from "../reactions/ReactionsReader";
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||||
@@ -108,6 +109,8 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
|
|||||||
import { type Layout } from "../state/layout-types.ts";
|
import { type Layout } from "../state/layout-types.ts";
|
||||||
import { ObservableScope } from "../state/ObservableScope.ts";
|
import { ObservableScope } from "../state/ObservableScope.ts";
|
||||||
|
|
||||||
|
const logger = rootLogger.getChild("[InCallView]");
|
||||||
|
|
||||||
const maxTapDurationMs = 400;
|
const maxTapDurationMs = 400;
|
||||||
|
|
||||||
export interface ActiveCallProps
|
export interface ActiveCallProps
|
||||||
@@ -126,6 +129,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
logger.info("START CALL VIEW SCOPE");
|
||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
|
||||||
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
|
||||||
@@ -140,15 +144,20 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
encryptionSystem: props.e2eeSystem,
|
encryptionSystem: props.e2eeSystem,
|
||||||
autoLeaveWhenOthersLeft,
|
autoLeaveWhenOthersLeft,
|
||||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||||
|
matrixRTCMode$: matrixRTCModeSetting.value$,
|
||||||
},
|
},
|
||||||
reactionsReader.raisedHands$,
|
reactionsReader.raisedHands$,
|
||||||
reactionsReader.reactions$,
|
reactionsReader.reactions$,
|
||||||
scope.behavior(trackProcessorState$),
|
scope.behavior(trackProcessorState$),
|
||||||
);
|
);
|
||||||
|
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
|
||||||
|
vm.join();
|
||||||
setVm(vm);
|
setVm(vm);
|
||||||
|
|
||||||
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
|
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
|
logger.info("END CALL VIEW SCOPE");
|
||||||
scope.end();
|
scope.end();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
@@ -249,7 +258,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
() => void toggleRaisedHand(),
|
() => void toggleRaisedHand(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const audioParticipants = useBehavior(vm.audioParticipants$);
|
const audioParticipants = useBehavior(vm.livekitRoomItems$);
|
||||||
const participantCount = useBehavior(vm.participantCount$);
|
const participantCount = useBehavior(vm.participantCount$);
|
||||||
const reconnecting = useBehavior(vm.reconnecting$);
|
const reconnecting = useBehavior(vm.reconnecting$);
|
||||||
const windowMode = useBehavior(vm.windowMode$);
|
const windowMode = useBehavior(vm.windowMode$);
|
||||||
@@ -266,7 +275,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
const ringOverlay = useBehavior(vm.ringOverlay$);
|
const ringOverlay = useBehavior(vm.ringOverlay$);
|
||||||
const fatalCallError = useBehavior(vm.fatalError$);
|
const fatalCallError = useBehavior(vm.fatalError$);
|
||||||
// Stop the rendering and throw for the error boundary
|
// Stop the rendering and throw for the error boundary
|
||||||
if (fatalCallError) throw fatalCallError;
|
if (fatalCallError) {
|
||||||
|
logger.debug("fatalCallError stop rendering", fatalCallError);
|
||||||
|
throw fatalCallError;
|
||||||
|
}
|
||||||
|
|
||||||
// We need to set the proper timings on the animation based upon the sound length.
|
// We need to set the proper timings on the animation based upon the sound length.
|
||||||
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
|
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ export const LobbyView: FC<Props> = ({
|
|||||||
waitingForInvite,
|
waitingForInvite,
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.info("[Lifecycle] GroupCallView Component mounted");
|
logger.info("[Lifecycle] LobbyView Component mounted");
|
||||||
return (): void => {
|
return (): void => {
|
||||||
logger.info("[Lifecycle] GroupCallView Component unmounted");
|
logger.info("[Lifecycle] LobbyView Component unmounted");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -106,22 +106,18 @@ async function joinRoomAfterInvite(
|
|||||||
|
|
||||||
export class CallTerminatedMessage extends Error {
|
export class CallTerminatedMessage extends Error {
|
||||||
/**
|
/**
|
||||||
|
* Creates a new CallTerminatedMessage.
|
||||||
|
*
|
||||||
|
* @param icon The icon to display with the message
|
||||||
* @param messageTitle The title of the call ended screen message (translated)
|
* @param messageTitle The title of the call ended screen message (translated)
|
||||||
|
* @param messageBody The message explaining the kind of termination
|
||||||
|
* (kick, ban, knock reject, etc.) (translated)
|
||||||
|
* @param reason The user-provided reason for the termination (kick/ban)
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
/**
|
|
||||||
* The icon to display with the message.
|
|
||||||
*/
|
|
||||||
public readonly icon: ComponentType<SVGAttributes<SVGElement>>,
|
public readonly icon: ComponentType<SVGAttributes<SVGElement>>,
|
||||||
messageTitle: string,
|
messageTitle: string,
|
||||||
/**
|
|
||||||
* The message explaining the kind of termination (kick, ban, knock reject,
|
|
||||||
* etc.) (translated)
|
|
||||||
*/
|
|
||||||
public readonly messageBody: string,
|
public readonly messageBody: string,
|
||||||
/**
|
|
||||||
* The user-provided reason for the termination (kick/ban)
|
|
||||||
*/
|
|
||||||
public readonly reason?: string,
|
public readonly reason?: string,
|
||||||
) {
|
) {
|
||||||
super(messageTitle);
|
super(messageTitle);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class ConsoleLogger extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the log lines to flush to disk and empties the internal log buffer
|
* Returns the log lines to flush to disk and empties the internal log buffer
|
||||||
* @return {string} \n delimited log lines
|
* @return \n delimited log lines
|
||||||
*/
|
*/
|
||||||
public popLogs(): string {
|
public popLogs(): string {
|
||||||
const logsToFlush = this.logs;
|
const logsToFlush = this.logs;
|
||||||
@@ -109,7 +109,7 @@ class ConsoleLogger extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns lines currently in the log buffer without removing them
|
* Returns lines currently in the log buffer without removing them
|
||||||
* @return {string} \n delimited log lines
|
* @return \n delimited log lines
|
||||||
*/
|
*/
|
||||||
public peekLogs(): string {
|
public peekLogs(): string {
|
||||||
return this.logs;
|
return this.logs;
|
||||||
@@ -139,7 +139,7 @@ class IndexedDBLogStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return {Promise} Resolves when the store is ready.
|
* @return Resolves when the store is ready.
|
||||||
*/
|
*/
|
||||||
public async connect(): Promise<void> {
|
public async connect(): Promise<void> {
|
||||||
const req = this.indexedDB.open("logs");
|
const req = this.indexedDB.open("logs");
|
||||||
@@ -219,7 +219,7 @@ class IndexedDBLogStore {
|
|||||||
* This guarantees that we will always eventually do a flush when flush() is
|
* This guarantees that we will always eventually do a flush when flush() is
|
||||||
* called.
|
* called.
|
||||||
*
|
*
|
||||||
* @return {Promise} Resolved when the logs have been flushed.
|
* @return Resolved when the logs have been flushed.
|
||||||
*/
|
*/
|
||||||
public flush = async (): Promise<void> => {
|
public flush = async (): Promise<void> => {
|
||||||
// check if a flush() operation is ongoing
|
// check if a flush() operation is ongoing
|
||||||
@@ -270,7 +270,7 @@ class IndexedDBLogStore {
|
|||||||
* returned are deleted at the same time, so this can be called at startup
|
* returned are deleted at the same time, so this can be called at startup
|
||||||
* to do house-keeping to keep the logs from growing too large.
|
* to do house-keeping to keep the logs from growing too large.
|
||||||
*
|
*
|
||||||
* @return {Promise<Object[]>} Resolves to an array of objects. The array is
|
* @return Resolves to an array of objects. The array is
|
||||||
* sorted in time (oldest first) based on when the log file was created (the
|
* sorted in time (oldest first) based on when the log file was created (the
|
||||||
* log ID). The objects have said log ID in an "id" field and "lines" which
|
* log ID). The objects have said log ID in an "id" field and "lines" which
|
||||||
* is a big string with all the new-line delimited logs.
|
* is a big string with all the new-line delimited logs.
|
||||||
@@ -421,12 +421,12 @@ class IndexedDBLogStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper method to collect results from a Cursor and promiseify it.
|
* Helper method to collect results from a Cursor and promiseify it.
|
||||||
* @param {ObjectStore|Index} store The store to perform openCursor on.
|
* @param store - The store to perform openCursor on.
|
||||||
* @param {IDBKeyRange=} keyRange Optional key range to apply on the cursor.
|
* @param keyRange - Optional key range to apply on the cursor.
|
||||||
* @param {Function} resultMapper A function which is repeatedly called with a
|
* @param resultMapper - A function which is repeatedly called with a
|
||||||
* Cursor.
|
* Cursor.
|
||||||
* Return the data you want to keep.
|
* Return the data you want to keep.
|
||||||
* @return {Promise<T[]>} Resolves to an array of whatever you returned from
|
* @return Resolves to an array of whatever you returned from
|
||||||
* resultMapper.
|
* resultMapper.
|
||||||
*/
|
*/
|
||||||
async function selectQuery<T>(
|
async function selectQuery<T>(
|
||||||
@@ -464,9 +464,7 @@ declare global {
|
|||||||
/**
|
/**
|
||||||
* Configure rage shaking support for sending bug reports.
|
* Configure rage shaking support for sending bug reports.
|
||||||
* Modifies globals.
|
* Modifies globals.
|
||||||
* @param {boolean} setUpPersistence When true (default), the persistence will
|
* @return Resolves when set up.
|
||||||
* be set up immediately for the logs.
|
|
||||||
* @return {Promise} Resolves when set up.
|
|
||||||
*/
|
*/
|
||||||
export async function init(): Promise<void> {
|
export async function init(): Promise<void> {
|
||||||
global.mx_rage_logger = new ConsoleLogger();
|
global.mx_rage_logger = new ConsoleLogger();
|
||||||
@@ -503,7 +501,7 @@ export async function init(): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Try to start up the rageshake storage for logs. If not possible (client unsupported)
|
* Try to start up the rageshake storage for logs. If not possible (client unsupported)
|
||||||
* then this no-ops.
|
* then this no-ops.
|
||||||
* @return {Promise} Resolves when complete.
|
* @return Resolves when complete.
|
||||||
*/
|
*/
|
||||||
async function tryInitStorage(): Promise<void> {
|
async function tryInitStorage(): Promise<void> {
|
||||||
if (global.mx_rage_initStoragePromise) {
|
if (global.mx_rage_initStoragePromise) {
|
||||||
@@ -536,7 +534,7 @@ async function tryInitStorage(): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Get a recent snapshot of the logs, ready for attaching to a bug report
|
* Get a recent snapshot of the logs, ready for attaching to a bug report
|
||||||
*
|
*
|
||||||
* @return {LogEntry[]} list of log data
|
* @return list of log data
|
||||||
*/
|
*/
|
||||||
export async function getLogsForReport(): Promise<LogEntry[]> {
|
export async function getLogsForReport(): Promise<LogEntry[]> {
|
||||||
if (!global.mx_rage_logger) {
|
if (!global.mx_rage_logger) {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export interface Props {
|
|||||||
localUser: { deviceId: string; userId: string };
|
localUser: { deviceId: string; userId: string };
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @returns {callPickupState$, autoLeave$}
|
* @returns two observables:
|
||||||
* `callPickupState$` The current call pickup state of the call.
|
* `callPickupState$` The current call pickup state of the call.
|
||||||
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
|
||||||
* Then we can conclude if we were the first one to join or not.
|
* Then we can conclude if we were the first one to join or not.
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ import {
|
|||||||
import { MediaDevices } from "../MediaDevices.ts";
|
import { MediaDevices } from "../MediaDevices.ts";
|
||||||
import { getValue } from "../../utils/observable.ts";
|
import { getValue } from "../../utils/observable.ts";
|
||||||
import { type Behavior, constant } from "../Behavior.ts";
|
import { type Behavior, constant } from "../Behavior.ts";
|
||||||
import { withCallViewModel } from "./CallViewModelTestUtils.ts";
|
import { withCallViewModel as withCallViewModelInMode } from "./CallViewModelTestUtils.ts";
|
||||||
|
import { MatrixRTCMode } from "../../settings/settings.ts";
|
||||||
|
|
||||||
vi.mock("rxjs", async (importOriginal) => ({
|
vi.mock("rxjs", async (importOriginal) => ({
|
||||||
...(await importOriginal()),
|
...(await importOriginal()),
|
||||||
@@ -229,7 +230,13 @@ function mockRingEvent(
|
|||||||
// need a value to fill in for them when emitting notifications
|
// need a value to fill in for them when emitting notifications
|
||||||
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
||||||
|
|
||||||
describe("CallViewModel", () => {
|
describe.each([
|
||||||
|
[MatrixRTCMode.Legacy],
|
||||||
|
[MatrixRTCMode.Compatibil],
|
||||||
|
[MatrixRTCMode.Matrix_2_0],
|
||||||
|
])("CallViewModel (%s mode)", (mode) => {
|
||||||
|
const withCallViewModel = withCallViewModelInMode(mode);
|
||||||
|
|
||||||
test("participants are retained during a focus switch", () => {
|
test("participants are retained during a focus switch", () => {
|
||||||
withTestScheduler(({ behavior, expectObservable }) => {
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
// Participants disappear on frame 2 and come back on frame 3
|
// Participants disappear on frame 2 and come back on frame 3
|
||||||
@@ -267,7 +274,7 @@ describe("CallViewModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip("screen sharing activates spotlight layout", () => {
|
test("screen sharing activates spotlight layout", () => {
|
||||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
// Start with no screen shares, then have Alice and Bob share their screens,
|
// Start with no screen shares, then have Alice and Bob share their screens,
|
||||||
// then return to no screen shares, then have just Alice share for a bit
|
// then return to no screen shares, then have just Alice share for a bit
|
||||||
@@ -502,6 +509,48 @@ describe("CallViewModel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("layout reacts to window size", () => {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
const windowSizeInputMarbles = "abc";
|
||||||
|
const expectedLayoutMarbles = " abc";
|
||||||
|
withCallViewModel(
|
||||||
|
{
|
||||||
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
|
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
|
||||||
|
windowSize$: behavior(windowSizeInputMarbles, {
|
||||||
|
a: { width: 300, height: 600 }, // Start very narrow, like a phone
|
||||||
|
b: { width: 1000, height: 800 }, // Go to normal desktop window size
|
||||||
|
c: { width: 200, height: 180 }, // Go to PiP size
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(vm) => {
|
||||||
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
|
expectedLayoutMarbles,
|
||||||
|
{
|
||||||
|
a: {
|
||||||
|
// This is the expected one-on-one layout for a narrow window
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
pip: `${localId}:0`,
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
// In a larger window, expect the normal one-on-one layout
|
||||||
|
type: "one-on-one",
|
||||||
|
local: `${localId}:0`,
|
||||||
|
remote: `${aliceId}:0`,
|
||||||
|
},
|
||||||
|
c: {
|
||||||
|
// In a PiP-sized window, we of course expect a PiP layout
|
||||||
|
type: "pip",
|
||||||
|
spotlight: [`${aliceId}:0`],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("spotlight speakers swap places", () => {
|
test("spotlight speakers swap places", () => {
|
||||||
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
// Go immediately into spotlight mode for the test
|
// Go immediately into spotlight mode for the test
|
||||||
@@ -1207,7 +1256,9 @@ describe("CallViewModel", () => {
|
|||||||
rtcSession.membershipStatus = Status.Connected;
|
rtcSession.membershipStatus = Status.Connected;
|
||||||
},
|
},
|
||||||
n: () => {
|
n: () => {
|
||||||
rtcSession.membershipStatus = Status.Reconnecting;
|
// NOTE: This was removed in https://github.com/matrix-org/matrix-js-sdk/pull/5103 accidentally.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
rtcSession.membershipStatus = "Reconnecting" as any;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
schedule(probablyLeftMarbles, {
|
schedule(probablyLeftMarbles, {
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import {
|
|||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
import { type Room as MatrixRoom } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
|
catchError,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
EMPTY,
|
|
||||||
filter,
|
filter,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
map,
|
map,
|
||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
pairwise,
|
pairwise,
|
||||||
race,
|
race,
|
||||||
scan,
|
scan,
|
||||||
skip,
|
|
||||||
skipWhile,
|
skipWhile,
|
||||||
startWith,
|
startWith,
|
||||||
Subject,
|
Subject,
|
||||||
@@ -54,11 +53,15 @@ import {
|
|||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
} from "../MediaViewModel";
|
} from "../MediaViewModel";
|
||||||
import { accumulate, generateItems, pauseWhen } from "../../utils/observable";
|
import {
|
||||||
|
accumulate,
|
||||||
|
filterBehavior,
|
||||||
|
generateItems,
|
||||||
|
pauseWhen,
|
||||||
|
} from "../../utils/observable";
|
||||||
import {
|
import {
|
||||||
duplicateTiles,
|
duplicateTiles,
|
||||||
MatrixRTCMode,
|
MatrixRTCMode,
|
||||||
matrixRTCMode,
|
|
||||||
playReactionsSound,
|
playReactionsSound,
|
||||||
showReactions,
|
showReactions,
|
||||||
} from "../../settings/settings";
|
} from "../../settings/settings";
|
||||||
@@ -77,7 +80,7 @@ import {
|
|||||||
} from "../../reactions";
|
} from "../../reactions";
|
||||||
import { shallowEquals } from "../../utils/array";
|
import { shallowEquals } from "../../utils/array";
|
||||||
import { type MediaDevices } from "../MediaDevices";
|
import { type MediaDevices } from "../MediaDevices";
|
||||||
import { type Behavior } from "../Behavior";
|
import { constant, type Behavior } from "../Behavior";
|
||||||
import { E2eeType } from "../../e2ee/e2eeType";
|
import { E2eeType } from "../../e2ee/e2eeType";
|
||||||
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
||||||
import { type MuteStates } from "../MuteStates";
|
import { type MuteStates } from "../MuteStates";
|
||||||
@@ -95,15 +98,14 @@ import {
|
|||||||
type SpotlightLandscapeLayoutMedia,
|
type SpotlightLandscapeLayoutMedia,
|
||||||
type SpotlightPortraitLayoutMedia,
|
type SpotlightPortraitLayoutMedia,
|
||||||
} from "../layout-types.ts";
|
} from "../layout-types.ts";
|
||||||
import { type ElementCallError } from "../../utils/errors.ts";
|
import { ElementCallError } from "../../utils/errors.ts";
|
||||||
import { type ObservableScope } from "../ObservableScope.ts";
|
import { type ObservableScope } from "../ObservableScope.ts";
|
||||||
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
||||||
import {
|
import {
|
||||||
createLocalMembership$,
|
createLocalMembership$,
|
||||||
enterRTCSession,
|
enterRTCSession,
|
||||||
LivekitState,
|
TransportState,
|
||||||
type LocalMemberConnectionState,
|
} from "./localMember/LocalMember.ts";
|
||||||
} from "./localMember/LocalMembership.ts";
|
|
||||||
import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
|
import { createLocalTransport$ } from "./localMember/LocalTransport.ts";
|
||||||
import {
|
import {
|
||||||
createMemberships$,
|
createMemberships$,
|
||||||
@@ -113,7 +115,9 @@ import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts";
|
|||||||
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts";
|
import { createConnectionManager$ } from "./remoteMembers/ConnectionManager.ts";
|
||||||
import {
|
import {
|
||||||
createMatrixLivekitMembers$,
|
createMatrixLivekitMembers$,
|
||||||
type MatrixLivekitMember,
|
type TaggedParticipant,
|
||||||
|
type LocalMatrixLivekitMember,
|
||||||
|
type RemoteMatrixLivekitMember,
|
||||||
} from "./remoteMembers/MatrixLivekitMembers.ts";
|
} from "./remoteMembers/MatrixLivekitMembers.ts";
|
||||||
import {
|
import {
|
||||||
type AutoLeaveReason,
|
type AutoLeaveReason,
|
||||||
@@ -128,6 +132,7 @@ import {
|
|||||||
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
} from "./remoteMembers/MatrixMemberMetadata.ts";
|
||||||
import { Publisher } from "./localMember/Publisher.ts";
|
import { Publisher } from "./localMember/Publisher.ts";
|
||||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||||
|
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||||
|
|
||||||
const logger = rootLogger.getChild("[CallViewModel]");
|
const logger = rootLogger.getChild("[CallViewModel]");
|
||||||
//TODO
|
//TODO
|
||||||
@@ -149,6 +154,10 @@ export interface CallViewModelOptions {
|
|||||||
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
|
livekitRoomFactory?: (options?: RoomOptions) => LivekitRoom;
|
||||||
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
|
/** Optional behavior overriding the local connection state, mainly for testing purposes. */
|
||||||
connectionState$?: Behavior<ConnectionState>;
|
connectionState$?: Behavior<ConnectionState>;
|
||||||
|
/** Optional behavior overriding the computed window size, mainly for testing purposes. */
|
||||||
|
windowSize$?: Behavior<{ width: number; height: number }>;
|
||||||
|
/** The version & compatibility mode of MatrixRTC that we should use. */
|
||||||
|
matrixRTCMode$?: Behavior<MatrixRTCMode>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not play any sounds if the participant count has exceeded this
|
// Do not play any sounds if the participant count has exceeded this
|
||||||
@@ -174,7 +183,7 @@ interface LayoutScanState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MediaItem = UserMedia | ScreenShare;
|
type MediaItem = UserMedia | ScreenShare;
|
||||||
type AudioLivekitItem = {
|
export type LivekitRoomItem = {
|
||||||
livekitRoom: LivekitRoom;
|
livekitRoom: LivekitRoom;
|
||||||
participants: string[];
|
participants: string[];
|
||||||
url: string;
|
url: string;
|
||||||
@@ -197,12 +206,15 @@ export interface CallViewModel {
|
|||||||
callPickupState$: Behavior<
|
callPickupState$: Behavior<
|
||||||
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
||||||
>;
|
>;
|
||||||
|
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
|
||||||
|
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is by ending the scope.
|
||||||
|
*/
|
||||||
leave$: Observable<"user" | AutoLeaveReason>;
|
leave$: Observable<"user" | AutoLeaveReason>;
|
||||||
/** Call to initiate hangup. Use in conbination with connectino state track the async hangup process. */
|
/** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */
|
||||||
hangup: () => void;
|
hangup: () => void;
|
||||||
|
|
||||||
// joining
|
// joining
|
||||||
join: () => LocalMemberConnectionState;
|
join: () => void;
|
||||||
|
|
||||||
// screen sharing
|
// screen sharing
|
||||||
/**
|
/**
|
||||||
@@ -250,7 +262,11 @@ export interface CallViewModel {
|
|||||||
*/
|
*/
|
||||||
participantCount$: Behavior<number>;
|
participantCount$: Behavior<number>;
|
||||||
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
||||||
audioParticipants$: Behavior<AudioLivekitItem[]>;
|
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
|
||||||
|
userMedia$: Behavior<UserMedia[]>;
|
||||||
|
/** use the layout instead, this is just for the sdk export. */
|
||||||
|
matrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
|
||||||
|
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
|
||||||
/** List of participants raising their hand */
|
/** List of participants raising their hand */
|
||||||
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
|
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
|
||||||
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
|
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
|
||||||
@@ -333,18 +349,17 @@ export interface CallViewModel {
|
|||||||
switch: () => void;
|
switch: () => void;
|
||||||
} | null>;
|
} | null>;
|
||||||
|
|
||||||
// connection state
|
|
||||||
/**
|
/**
|
||||||
* Whether various media/event sources should pretend to be disconnected from
|
* Whether the app is currently reconnecting to the LiveKit server and/or setting the matrix rtc room state.
|
||||||
* all network input, even if their connection still technically works.
|
|
||||||
*/
|
*/
|
||||||
// We do this when the app is in the 'reconnecting' state, because it might be
|
|
||||||
// that the LiveKit connection is still functional while the homeserver is
|
|
||||||
// down, for example, and we want to avoid making people worry that the app is
|
|
||||||
// in a split-brained state.
|
|
||||||
// DISCUSSION own membership manager ALSO this probably can be simplifis
|
|
||||||
reconnecting$: Behavior<boolean>;
|
reconnecting$: Behavior<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut for not requireing to parse and combine connectionState.matrix and connectionState.livekit
|
||||||
|
*/
|
||||||
|
connected$: Behavior<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A view model providing all the application logic needed to show the in-call
|
* A view model providing all the application logic needed to show the in-call
|
||||||
* UI (may eventually be expanded to cover the lobby and feedback screens in the
|
* UI (may eventually be expanded to cover the lobby and feedback screens in the
|
||||||
@@ -372,6 +387,8 @@ export function createCallViewModel$(
|
|||||||
options.encryptionSystem,
|
options.encryptionSystem,
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
);
|
);
|
||||||
|
const matrixRTCMode$ =
|
||||||
|
options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy);
|
||||||
|
|
||||||
// Each hbar seperates a block of input variables required for the CallViewModel to function.
|
// Each hbar seperates a block of input variables required for the CallViewModel to function.
|
||||||
// The outputs of this block is written under the hbar.
|
// The outputs of this block is written under the hbar.
|
||||||
@@ -404,7 +421,7 @@ export function createCallViewModel$(
|
|||||||
client,
|
client,
|
||||||
roomId: matrixRoom.roomId,
|
roomId: matrixRoom.roomId,
|
||||||
useOldestMember$: scope.behavior(
|
useOldestMember$: scope.behavior(
|
||||||
matrixRTCMode.value$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -415,6 +432,8 @@ export function createCallViewModel$(
|
|||||||
livekitKeyProvider,
|
livekitKeyProvider,
|
||||||
getUrlParams().controlledAudioDevices,
|
getUrlParams().controlledAudioDevices,
|
||||||
options.livekitRoomFactory,
|
options.livekitRoomFactory,
|
||||||
|
getUrlParams().echoCancellation,
|
||||||
|
getUrlParams().noiseSuppression,
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectionManager = createConnectionManager$({
|
const connectionManager = createConnectionManager$({
|
||||||
@@ -422,7 +441,18 @@ export function createCallViewModel$(
|
|||||||
connectionFactory: connectionFactory,
|
connectionFactory: connectionFactory,
|
||||||
inputTransports$: scope.behavior(
|
inputTransports$: scope.behavior(
|
||||||
combineLatest(
|
combineLatest(
|
||||||
[localTransport$, membershipsAndTransports.transports$],
|
[
|
||||||
|
localTransport$.pipe(
|
||||||
|
catchError((e: unknown) => {
|
||||||
|
logger.info(
|
||||||
|
"dont pass local transport to createConnectionManager$. localTransport$ threw an error",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
membershipsAndTransports.transports$,
|
||||||
|
],
|
||||||
(localTransport, transports) => {
|
(localTransport, transports) => {
|
||||||
const localTransportAsArray = localTransport ? [localTransport] : [];
|
const localTransportAsArray = localTransport ? [localTransport] : [];
|
||||||
return transports.mapInner((transports) => [
|
return transports.mapInner((transports) => [
|
||||||
@@ -432,7 +462,7 @@ export function createCallViewModel$(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
logger: logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||||
@@ -443,7 +473,7 @@ export function createCallViewModel$(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const connectOptions$ = scope.behavior(
|
const connectOptions$ = scope.behavior(
|
||||||
matrixRTCMode.value$.pipe(
|
matrixRTCMode$.pipe(
|
||||||
map((mode) => ({
|
map((mode) => ({
|
||||||
encryptMedia: livekitKeyProvider !== undefined,
|
encryptMedia: livekitKeyProvider !== undefined,
|
||||||
// TODO. This might need to get called again on each change of matrixRTCMode...
|
// TODO. This might need to get called again on each change of matrixRTCMode...
|
||||||
@@ -454,13 +484,13 @@ export function createCallViewModel$(
|
|||||||
|
|
||||||
const localMembership = createLocalMembership$({
|
const localMembership = createLocalMembership$({
|
||||||
scope: scope,
|
scope: scope,
|
||||||
homeserverConnected$: createHomeserverConnected$(
|
homeserverConnected: createHomeserverConnected$(
|
||||||
scope,
|
scope,
|
||||||
client,
|
client,
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
),
|
),
|
||||||
muteStates: muteStates,
|
muteStates: muteStates,
|
||||||
joinMatrixRTC: async (transport: LivekitTransport) => {
|
joinMatrixRTC: (transport: LivekitTransport) => {
|
||||||
return enterRTCSession(
|
return enterRTCSession(
|
||||||
matrixRTCSession,
|
matrixRTCSession,
|
||||||
transport,
|
transport,
|
||||||
@@ -474,6 +504,9 @@ export function createCallViewModel$(
|
|||||||
mediaDevices,
|
mediaDevices,
|
||||||
muteStates,
|
muteStates,
|
||||||
trackProcessorState$,
|
trackProcessorState$,
|
||||||
|
logger.getChild(
|
||||||
|
"[Publisher " + connection.transport.livekit_service_url + "]",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
connectionManager: connectionManager,
|
connectionManager: connectionManager,
|
||||||
@@ -494,22 +527,21 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const localMatrixLivekitMemberUninitialized = {
|
const localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null> =
|
||||||
membership$: localRtcMembership$,
|
|
||||||
participant$: localMembership.participant$,
|
|
||||||
connection$: localMembership.connection$,
|
|
||||||
userId: userId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const localMatrixLivekitMember$: Behavior<MatrixLivekitMember | null> =
|
|
||||||
scope.behavior(
|
scope.behavior(
|
||||||
localRtcMembership$.pipe(
|
localRtcMembership$.pipe(
|
||||||
switchMap((membership) => {
|
filterBehavior((membership) => membership !== null),
|
||||||
if (!membership) return of(null);
|
map((membership$) => {
|
||||||
return of(
|
if (membership$ === null) return null;
|
||||||
// casting is save here since we know that localRtcMembership$ is !== null since we reached this case.
|
return {
|
||||||
localMatrixLivekitMemberUninitialized as MatrixLivekitMember,
|
membership$,
|
||||||
);
|
participant: {
|
||||||
|
type: "local" as const,
|
||||||
|
value$: localMembership.participant$,
|
||||||
|
},
|
||||||
|
connection$: localMembership.connection$,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -572,34 +604,16 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// CODESMELL?
|
const livekitRoomItems$ = scope.behavior(
|
||||||
// This is functionally the same Observable as leave$, except here it's
|
|
||||||
// hoisted to the top of the class. This enables the cyclic dependency between
|
|
||||||
// leave$ -> autoLeave$ -> callPickupState$ -> livekitConnectionState$ ->
|
|
||||||
// localConnection$ -> transports$ -> joined$ -> leave$.
|
|
||||||
const leaveHoisted$ = new Subject<
|
|
||||||
"user" | "timeout" | "decline" | "allOthersLeft"
|
|
||||||
>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether various media/event sources should pretend to be disconnected from
|
|
||||||
* all network input, even if their connection still technically works.
|
|
||||||
*/
|
|
||||||
// We do this when the app is in the 'reconnecting' state, because it might be
|
|
||||||
// that the LiveKit connection is still functional while the homeserver is
|
|
||||||
// down, for example, and we want to avoid making people worry that the app is
|
|
||||||
// in a split-brained state.
|
|
||||||
// DISCUSSION own membership manager ALSO this probably can be simplifis
|
|
||||||
const reconnecting$ = localMembership.reconnecting$;
|
|
||||||
const pretendToBeDisconnected$ = reconnecting$;
|
|
||||||
|
|
||||||
const audioParticipants$ = scope.behavior(
|
|
||||||
matrixLivekitMembers$.pipe(
|
matrixLivekitMembers$.pipe(
|
||||||
|
tap((val) => {
|
||||||
|
logger.debug("matrixLivekitMembers$ updated", val.value);
|
||||||
|
}),
|
||||||
switchMap((membersWithEpoch) => {
|
switchMap((membersWithEpoch) => {
|
||||||
const members = membersWithEpoch.value;
|
const members = membersWithEpoch.value;
|
||||||
const a$ = combineLatest(
|
const a$ = combineLatest(
|
||||||
members.map((member) =>
|
members.map((member) =>
|
||||||
combineLatest([member.connection$, member.participant$]).pipe(
|
combineLatest([member.connection$, member.participant.value$]).pipe(
|
||||||
map(([connection, participant]) => {
|
map(([connection, participant]) => {
|
||||||
// do not render audio for local participant
|
// do not render audio for local participant
|
||||||
if (!connection || !participant || participant.isLocal)
|
if (!connection || !participant || participant.isLocal)
|
||||||
@@ -619,7 +633,7 @@ export function createCallViewModel$(
|
|||||||
return a$;
|
return a$;
|
||||||
}),
|
}),
|
||||||
map((members) =>
|
map((members) =>
|
||||||
members.reduce<AudioLivekitItem[]>((acc, curr) => {
|
members.reduce<LivekitRoomItem[]>((acc, curr) => {
|
||||||
if (!curr) return acc;
|
if (!curr) return acc;
|
||||||
|
|
||||||
const existing = acc.find((item) => item.url === curr.url);
|
const existing = acc.find((item) => item.url === curr.url);
|
||||||
@@ -640,7 +654,7 @@ export function createCallViewModel$(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handsRaised$ = scope.behavior(
|
const handsRaised$ = scope.behavior(
|
||||||
handsRaisedSubject$.pipe(pauseWhen(pretendToBeDisconnected$)),
|
handsRaisedSubject$.pipe(pauseWhen(localMembership.reconnecting$)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reactions$ = scope.behavior(
|
const reactions$ = scope.behavior(
|
||||||
@@ -653,7 +667,7 @@ export function createCallViewModel$(
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
pauseWhen(pretendToBeDisconnected$),
|
pauseWhen(localMembership.reconnecting$),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -674,10 +688,10 @@ export function createCallViewModel$(
|
|||||||
{ value: matrixLivekitMembers },
|
{ value: matrixLivekitMembers },
|
||||||
duplicateTiles,
|
duplicateTiles,
|
||||||
]) {
|
]) {
|
||||||
let localParticipantId = undefined;
|
let localParticipantId: string | undefined = undefined;
|
||||||
// add local member if available
|
// add local member if available
|
||||||
if (localMatrixLivekitMember) {
|
if (localMatrixLivekitMember) {
|
||||||
const { userId, participant$, connection$, membership$ } =
|
const { userId, participant, connection$, membership$ } =
|
||||||
localMatrixLivekitMember;
|
localMatrixLivekitMember;
|
||||||
localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
|
localParticipantId = `${userId}:${membership$.value.deviceId}`; // should be membership$.value.membershipID which is not optional
|
||||||
// const participantId = membership$.value.membershipID;
|
// const participantId = membership$.value.membershipID;
|
||||||
@@ -688,7 +702,7 @@ export function createCallViewModel$(
|
|||||||
dup,
|
dup,
|
||||||
localParticipantId,
|
localParticipantId,
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant satisfies TaggedParticipant as TaggedParticipant, // Widen the type safely
|
||||||
connection$,
|
connection$,
|
||||||
],
|
],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
@@ -699,7 +713,7 @@ export function createCallViewModel$(
|
|||||||
// add remote members that are available
|
// add remote members that are available
|
||||||
for (const {
|
for (const {
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant,
|
||||||
connection$,
|
connection$,
|
||||||
membership$,
|
membership$,
|
||||||
} of matrixLivekitMembers) {
|
} of matrixLivekitMembers) {
|
||||||
@@ -708,7 +722,7 @@ export function createCallViewModel$(
|
|||||||
// const participantId = membership$.value?.identity;
|
// const participantId = membership$.value?.identity;
|
||||||
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
|
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
|
||||||
yield {
|
yield {
|
||||||
keys: [dup, participantId, userId, participant$, connection$],
|
keys: [dup, participantId, userId, participant, connection$],
|
||||||
data: undefined,
|
data: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -720,7 +734,7 @@ export function createCallViewModel$(
|
|||||||
dup,
|
dup,
|
||||||
participantId,
|
participantId,
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant,
|
||||||
connection$,
|
connection$,
|
||||||
) => {
|
) => {
|
||||||
const livekitRoom$ = scope.behavior(
|
const livekitRoom$ = scope.behavior(
|
||||||
@@ -739,12 +753,12 @@ export function createCallViewModel$(
|
|||||||
scope,
|
scope,
|
||||||
`${participantId}:${dup}`,
|
`${participantId}:${dup}`,
|
||||||
userId,
|
userId,
|
||||||
participant$,
|
participant,
|
||||||
options.encryptionSystem,
|
options.encryptionSystem,
|
||||||
livekitRoom$,
|
livekitRoom$,
|
||||||
focusUrl$,
|
focusUrl$,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
pretendToBeDisconnected$,
|
localMembership.reconnecting$,
|
||||||
displayName$,
|
displayName$,
|
||||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||||
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
|
handsRaised$.pipe(map((v) => v[participantId]?.time ?? null)),
|
||||||
@@ -840,10 +854,7 @@ export function createCallViewModel$(
|
|||||||
merge(
|
merge(
|
||||||
autoLeave$,
|
autoLeave$,
|
||||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
||||||
).pipe(
|
).pipe(scope.share);
|
||||||
scope.share,
|
|
||||||
tap((reason) => leaveHoisted$.next(reason)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
|
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
|
||||||
userMedia$.pipe(
|
userMedia$.pipe(
|
||||||
@@ -952,20 +963,29 @@ export function createCallViewModel$(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasRemoteScreenShares$: Observable<boolean> = spotlight$.pipe(
|
const hasRemoteScreenShares$ = scope.behavior<boolean>(
|
||||||
map((spotlight) =>
|
spotlight$.pipe(
|
||||||
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
map((spotlight) =>
|
||||||
|
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
distinctUntilChanged(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
const pipEnabled$ = scope.behavior(setPipEnabled$, false);
|
||||||
|
|
||||||
|
const windowSize$ =
|
||||||
|
options.windowSize$ ??
|
||||||
|
scope.behavior<{ width: number; height: number }>(
|
||||||
|
fromEvent(window, "resize").pipe(
|
||||||
|
startWith(null),
|
||||||
|
map(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// A guess at what the window's mode should be based on its size and shape.
|
||||||
const naturalWindowMode$ = scope.behavior<WindowMode>(
|
const naturalWindowMode$ = scope.behavior<WindowMode>(
|
||||||
fromEvent(window, "resize").pipe(
|
windowSize$.pipe(
|
||||||
map(() => {
|
map(({ width, height }) => {
|
||||||
const height = window.innerHeight;
|
|
||||||
const width = window.innerWidth;
|
|
||||||
if (height <= 400 && width <= 340) return "pip";
|
if (height <= 400 && width <= 340) return "pip";
|
||||||
// Our layouts for flat windows are better at adapting to a small width
|
// Our layouts for flat windows are better at adapting to a small width
|
||||||
// than our layouts for narrow windows are at adapting to a small height,
|
// than our layouts for narrow windows are at adapting to a small height,
|
||||||
@@ -975,7 +995,6 @@ export function createCallViewModel$(
|
|||||||
return "normal";
|
return "normal";
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
"normal",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -992,36 +1011,11 @@ export function createCallViewModel$(
|
|||||||
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
|
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const gridModeUserSelection$ = new Subject<GridMode>();
|
const { setGridMode, gridMode$ } = createLayoutModeSwitch(
|
||||||
/**
|
scope,
|
||||||
* The layout mode of the media tile grid.
|
windowMode$,
|
||||||
*/
|
hasRemoteScreenShares$,
|
||||||
const gridMode$ =
|
);
|
||||||
// If the user hasn't selected spotlight and somebody starts screen sharing,
|
|
||||||
// automatically switch to spotlight mode and reset when screen sharing ends
|
|
||||||
scope.behavior<GridMode>(
|
|
||||||
gridModeUserSelection$.pipe(
|
|
||||||
switchMap((userSelection) =>
|
|
||||||
(userSelection === "spotlight"
|
|
||||||
? EMPTY
|
|
||||||
: combineLatest([hasRemoteScreenShares$, windowMode$]).pipe(
|
|
||||||
skip(userSelection === null ? 0 : 1),
|
|
||||||
map(
|
|
||||||
([hasScreenShares, windowMode]): GridMode =>
|
|
||||||
hasScreenShares || windowMode === "flat"
|
|
||||||
? "spotlight"
|
|
||||||
: "grid",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).pipe(startWith(userSelection ?? "grid")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"grid",
|
|
||||||
);
|
|
||||||
|
|
||||||
const setGridMode = (value: GridMode): void => {
|
|
||||||
gridModeUserSelection$.next(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
|
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
|
||||||
[grid$, spotlight$],
|
[grid$, spotlight$],
|
||||||
@@ -1448,16 +1442,44 @@ export function createCallViewModel$(
|
|||||||
// reassigned here to make it publicly accessible
|
// reassigned here to make it publicly accessible
|
||||||
const toggleScreenSharing = localMembership.toggleScreenSharing;
|
const toggleScreenSharing = localMembership.toggleScreenSharing;
|
||||||
|
|
||||||
const join = localMembership.requestConnect;
|
const errors$ = scope.behavior<{
|
||||||
// TODO-MULTI-SFU: Use this view model for the lobby as well, and only call this once 'join' is clicked?
|
transportError?: ElementCallError;
|
||||||
join();
|
matrixError?: ElementCallError;
|
||||||
|
connectionError?: ElementCallError;
|
||||||
|
publishError?: ElementCallError;
|
||||||
|
} | null>(
|
||||||
|
localMembership.localMemberState$.pipe(
|
||||||
|
map((value) => {
|
||||||
|
const returnObject: {
|
||||||
|
transportError?: ElementCallError;
|
||||||
|
matrixError?: ElementCallError;
|
||||||
|
connectionError?: ElementCallError;
|
||||||
|
publishError?: ElementCallError;
|
||||||
|
} = {};
|
||||||
|
if (value instanceof ElementCallError) return { transportError: value };
|
||||||
|
if (value === TransportState.Waiting) return null;
|
||||||
|
if (value.matrix instanceof ElementCallError)
|
||||||
|
returnObject.matrixError = value.matrix;
|
||||||
|
if (value.media instanceof ElementCallError)
|
||||||
|
returnObject.publishError = value.media;
|
||||||
|
else if (
|
||||||
|
typeof value.media === "object" &&
|
||||||
|
value.media.connection instanceof ElementCallError
|
||||||
|
)
|
||||||
|
returnObject.connectionError = value.media.connection;
|
||||||
|
return returnObject;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
autoLeave$: autoLeave$,
|
autoLeave$: autoLeave$,
|
||||||
callPickupState$: callPickupState$,
|
callPickupState$: callPickupState$,
|
||||||
ringOverlay$: ringOverlay$,
|
ringOverlay$: ringOverlay$,
|
||||||
leave$: leave$,
|
leave$: leave$,
|
||||||
hangup: (): void => userHangup$.next(),
|
hangup: (): void => userHangup$.next(),
|
||||||
join: join,
|
join: localMembership.requestJoinAndPublish,
|
||||||
toggleScreenSharing: toggleScreenSharing,
|
toggleScreenSharing: toggleScreenSharing,
|
||||||
sharingScreen$: sharingScreen$,
|
sharingScreen$: sharingScreen$,
|
||||||
|
|
||||||
@@ -1467,16 +1489,21 @@ export function createCallViewModel$(
|
|||||||
unhoverScreen: (): void => screenUnhover$.next(),
|
unhoverScreen: (): void => screenUnhover$.next(),
|
||||||
|
|
||||||
fatalError$: scope.behavior(
|
fatalError$: scope.behavior(
|
||||||
localMembership.connectionState.livekit$.pipe(
|
errors$.pipe(
|
||||||
filter((v) => v.state === LivekitState.Error),
|
map((errors) => {
|
||||||
map((s) => s.error),
|
logger.debug("errors$ to compute any fatal errors:", errors);
|
||||||
|
return (
|
||||||
|
errors?.transportError ??
|
||||||
|
errors?.matrixError ??
|
||||||
|
errors?.connectionError ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
filter((error) => error !== null),
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
),
|
),
|
||||||
|
|
||||||
participantCount$: participantCount$,
|
participantCount$: participantCount$,
|
||||||
audioParticipants$: audioParticipants$,
|
|
||||||
|
|
||||||
handsRaised$: handsRaised$,
|
handsRaised$: handsRaised$,
|
||||||
reactions$: reactions$,
|
reactions$: reactions$,
|
||||||
joinSoundEffect$: joinSoundEffect$,
|
joinSoundEffect$: joinSoundEffect$,
|
||||||
@@ -1495,6 +1522,16 @@ export function createCallViewModel$(
|
|||||||
spotlight$: spotlight$,
|
spotlight$: spotlight$,
|
||||||
pip$: pip$,
|
pip$: pip$,
|
||||||
layout$: layout$,
|
layout$: layout$,
|
||||||
|
userMedia$,
|
||||||
|
localMatrixLivekitMember$,
|
||||||
|
matrixLivekitMembers$: scope.behavior(
|
||||||
|
matrixLivekitMembers$.pipe(
|
||||||
|
map((members) => members.value),
|
||||||
|
tap((v) => {
|
||||||
|
logger.debug("matrixLivekitMembers$ updated (exported)", v);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
tileStoreGeneration$: tileStoreGeneration$,
|
tileStoreGeneration$: tileStoreGeneration$,
|
||||||
showSpotlightIndicators$: showSpotlightIndicators$,
|
showSpotlightIndicators$: showSpotlightIndicators$,
|
||||||
showSpeakingIndicators$: showSpeakingIndicators$,
|
showSpeakingIndicators$: showSpeakingIndicators$,
|
||||||
@@ -1502,7 +1539,9 @@ export function createCallViewModel$(
|
|||||||
showFooter$: showFooter$,
|
showFooter$: showFooter$,
|
||||||
earpieceMode$: earpieceMode$,
|
earpieceMode$: earpieceMode$,
|
||||||
audioOutputSwitcher$: audioOutputSwitcher$,
|
audioOutputSwitcher$: audioOutputSwitcher$,
|
||||||
reconnecting$: reconnecting$,
|
reconnecting$: localMembership.reconnecting$,
|
||||||
|
livekitRoomItems$,
|
||||||
|
connected$: localMembership.connected$,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
import { type Behavior, constant } from "../Behavior";
|
import { type Behavior, constant } from "../Behavior";
|
||||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||||
import { type MediaDevices } from "../MediaDevices";
|
import { type MediaDevices } from "../MediaDevices";
|
||||||
|
import { type MatrixRTCMode } from "../../settings/settings";
|
||||||
|
|
||||||
mockConfig({
|
mockConfig({
|
||||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||||
@@ -75,119 +76,130 @@ export interface CallViewModelInputs {
|
|||||||
speaking: Map<Participant, Observable<boolean>>;
|
speaking: Map<Participant, Observable<boolean>>;
|
||||||
mediaDevices: MediaDevices;
|
mediaDevices: MediaDevices;
|
||||||
initialSyncState: SyncState;
|
initialSyncState: SyncState;
|
||||||
|
windowSize$: Behavior<{ width: number; height: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localParticipant = mockLocalParticipant({ identity: "" });
|
const localParticipant = mockLocalParticipant({ identity: "" });
|
||||||
|
|
||||||
export function withCallViewModel(
|
export function withCallViewModel(mode: MatrixRTCMode) {
|
||||||
{
|
return (
|
||||||
remoteParticipants$ = constant([]),
|
{
|
||||||
rtcMembers$ = constant([localRtcMember]),
|
remoteParticipants$ = constant([]),
|
||||||
livekitConnectionState$: connectionState$ = constant(
|
rtcMembers$ = constant([localRtcMember]),
|
||||||
ConnectionState.Connected,
|
livekitConnectionState$: connectionState$ = constant(
|
||||||
),
|
ConnectionState.Connected,
|
||||||
speaking = new Map(),
|
),
|
||||||
mediaDevices = mockMediaDevices({}),
|
speaking = new Map(),
|
||||||
initialSyncState = SyncState.Syncing,
|
mediaDevices = mockMediaDevices({}),
|
||||||
}: Partial<CallViewModelInputs> = {},
|
initialSyncState = SyncState.Syncing,
|
||||||
continuation: (
|
windowSize$ = constant({ width: 1000, height: 800 }),
|
||||||
vm: CallViewModel,
|
}: Partial<CallViewModelInputs> = {},
|
||||||
rtcSession: MockRTCSession,
|
continuation: (
|
||||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
vm: CallViewModel,
|
||||||
setSyncState: (value: SyncState) => void,
|
rtcSession: MockRTCSession,
|
||||||
) => void,
|
subjects: {
|
||||||
options: CallViewModelOptions = {
|
raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
},
|
||||||
autoLeaveWhenOthersLeft: false,
|
setSyncState: (value: SyncState) => void,
|
||||||
},
|
) => void,
|
||||||
): void {
|
options: Partial<CallViewModelOptions> = {},
|
||||||
let syncState = initialSyncState;
|
): void => {
|
||||||
const setSyncState = (value: SyncState): void => {
|
let syncState = initialSyncState;
|
||||||
const prev = syncState;
|
const setSyncState = (value: SyncState): void => {
|
||||||
syncState = value;
|
const prev = syncState;
|
||||||
room.client.emit(ClientEvent.Sync, value, prev);
|
syncState = value;
|
||||||
};
|
room.client.emit(ClientEvent.Sync, value, prev);
|
||||||
const room = mockMatrixRoom({
|
};
|
||||||
client: new (class extends EventEmitter {
|
const room = mockMatrixRoom({
|
||||||
public getUserId(): string | undefined {
|
client: new (class extends EventEmitter {
|
||||||
return localRtcMember.userId;
|
public getUserId(): string | undefined {
|
||||||
}
|
return localRtcMember.userId;
|
||||||
|
}
|
||||||
|
|
||||||
public getDeviceId(): string {
|
public getDeviceId(): string {
|
||||||
return localRtcMember.deviceId;
|
return localRtcMember.deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDomain(): string {
|
public getDomain(): string {
|
||||||
return "example.com";
|
return "example.com";
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSyncState(): SyncState {
|
public getSyncState(): SyncState {
|
||||||
return syncState;
|
return syncState;
|
||||||
}
|
}
|
||||||
})() as Partial<MatrixClient> as MatrixClient,
|
})() as Partial<MatrixClient> as MatrixClient,
|
||||||
getMembers: () => Array.from(roomMembers.values()),
|
getMembers: () => Array.from(roomMembers.values()),
|
||||||
getMembersWithMembership: () => Array.from(roomMembers.values()),
|
getMembersWithMembership: () => Array.from(roomMembers.values()),
|
||||||
});
|
});
|
||||||
const rtcSession = new MockRTCSession(room, []).withMemberships(rtcMembers$);
|
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||||
const participantsSpy = vi
|
rtcMembers$,
|
||||||
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
|
||||||
.mockReturnValue(remoteParticipants$);
|
|
||||||
const mediaSpy = vi
|
|
||||||
.spyOn(ComponentsCore, "observeParticipantMedia")
|
|
||||||
.mockImplementation((p) =>
|
|
||||||
of({ participant: p } as Partial<
|
|
||||||
ComponentsCore.ParticipantMedia<LocalParticipant>
|
|
||||||
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
|
|
||||||
);
|
);
|
||||||
const eventsSpy = vi
|
const participantsSpy = vi
|
||||||
.spyOn(ComponentsCore, "observeParticipantEvents")
|
.spyOn(ComponentsCore, "connectedParticipantsObserver")
|
||||||
.mockImplementation((p, ...eventTypes) => {
|
.mockReturnValue(remoteParticipants$);
|
||||||
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
|
const mediaSpy = vi
|
||||||
return (speaking.get(p) ?? of(false)).pipe(
|
.spyOn(ComponentsCore, "observeParticipantMedia")
|
||||||
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
|
.mockImplementation((p) =>
|
||||||
);
|
of({ participant: p } as Partial<
|
||||||
} else {
|
ComponentsCore.ParticipantMedia<LocalParticipant>
|
||||||
return of(p);
|
> as ComponentsCore.ParticipantMedia<LocalParticipant>),
|
||||||
}
|
);
|
||||||
|
const eventsSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "observeParticipantEvents")
|
||||||
|
.mockImplementation((p, ...eventTypes) => {
|
||||||
|
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
|
||||||
|
return (speaking.get(p) ?? of(false)).pipe(
|
||||||
|
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return of(p);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const roomEventSelectorSpy = vi
|
||||||
|
.spyOn(ComponentsCore, "roomEventSelector")
|
||||||
|
.mockImplementation((_room, _eventType) => of());
|
||||||
|
const muteStates = mockMuteStates();
|
||||||
|
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
|
||||||
|
|
||||||
|
const vm = createCallViewModel$(
|
||||||
|
testScope(),
|
||||||
|
rtcSession.asMockedSession(),
|
||||||
|
room,
|
||||||
|
mediaDevices,
|
||||||
|
muteStates,
|
||||||
|
{
|
||||||
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
|
autoLeaveWhenOthersLeft: false,
|
||||||
|
livekitRoomFactory: (): LivekitRoom =>
|
||||||
|
mockLivekitRoom({
|
||||||
|
localParticipant,
|
||||||
|
disconnect: async () => Promise.resolve(),
|
||||||
|
setE2EEEnabled: async () => Promise.resolve(),
|
||||||
|
}),
|
||||||
|
connectionState$,
|
||||||
|
windowSize$,
|
||||||
|
matrixRTCMode$: constant(mode),
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
raisedHands$,
|
||||||
|
reactions$,
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
processor: undefined,
|
||||||
|
supported: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onTestFinished(() => {
|
||||||
|
participantsSpy.mockRestore();
|
||||||
|
mediaSpy.mockRestore();
|
||||||
|
eventsSpy.mockRestore();
|
||||||
|
roomEventSelectorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
const roomEventSelectorSpy = vi
|
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
|
||||||
.spyOn(ComponentsCore, "roomEventSelector")
|
};
|
||||||
.mockImplementation((_room, _eventType) => of());
|
|
||||||
const muteStates = mockMuteStates();
|
|
||||||
const raisedHands$ = new BehaviorSubject<Record<string, RaisedHandInfo>>({});
|
|
||||||
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
|
|
||||||
|
|
||||||
const vm = createCallViewModel$(
|
|
||||||
testScope(),
|
|
||||||
rtcSession.asMockedSession(),
|
|
||||||
room,
|
|
||||||
mediaDevices,
|
|
||||||
muteStates,
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
livekitRoomFactory: (): LivekitRoom =>
|
|
||||||
mockLivekitRoom({
|
|
||||||
localParticipant,
|
|
||||||
disconnect: async () => Promise.resolve(),
|
|
||||||
setE2EEEnabled: async () => Promise.resolve(),
|
|
||||||
}),
|
|
||||||
connectionState$,
|
|
||||||
},
|
|
||||||
raisedHands$,
|
|
||||||
reactions$,
|
|
||||||
new BehaviorSubject<ProcessorState>({
|
|
||||||
processor: undefined,
|
|
||||||
supported: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
onTestFinished(() => {
|
|
||||||
participantsSpy.mockRestore();
|
|
||||||
mediaSpy.mockRestore();
|
|
||||||
eventsSpy.mockRestore();
|
|
||||||
roomEventSelectorSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
continuation(vm, rtcSession, { raisedHands$: raisedHands$ }, setSyncState);
|
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/state/CallViewModel/LayoutSwitch.test.ts
Normal file
132
src/state/CallViewModel/LayoutSwitch.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test } from "vitest";
|
||||||
|
|
||||||
|
import { createLayoutModeSwitch } from "./LayoutSwitch";
|
||||||
|
import { testScope, withTestScheduler } from "../../utils/test";
|
||||||
|
|
||||||
|
function testLayoutSwitch({
|
||||||
|
windowMode = "n",
|
||||||
|
hasScreenShares = "n",
|
||||||
|
userSelection = "",
|
||||||
|
expectedGridMode,
|
||||||
|
}: {
|
||||||
|
windowMode?: string;
|
||||||
|
hasScreenShares?: string;
|
||||||
|
userSelection?: string;
|
||||||
|
expectedGridMode: string;
|
||||||
|
}): void {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
const { gridMode$, setGridMode } = createLayoutModeSwitch(
|
||||||
|
testScope(),
|
||||||
|
behavior(windowMode, { n: "normal", N: "narrow", f: "flat" }),
|
||||||
|
behavior(hasScreenShares, { y: true, n: false }),
|
||||||
|
);
|
||||||
|
schedule(userSelection, {
|
||||||
|
g: () => setGridMode("grid"),
|
||||||
|
s: () => setGridMode("spotlight"),
|
||||||
|
});
|
||||||
|
expectObservable(gridMode$).toBe(expectedGridMode, {
|
||||||
|
g: "grid",
|
||||||
|
s: "spotlight",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("default mode", () => {
|
||||||
|
test("uses grid layout by default", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
expectedGridMode: "g",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("uses spotlight mode when window mode is flat", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
windowMode: " f",
|
||||||
|
expectedGridMode: "s",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows switching modes manually", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
userSelection: " --sgs",
|
||||||
|
expectedGridMode: "g-sgs",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("switches to spotlight mode when there is a remote screen share", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
hasScreenShares: " n--y",
|
||||||
|
expectedGridMode: "g--s",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("can manually switch to grid when there is a screenshare", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
hasScreenShares: " n-y",
|
||||||
|
userSelection: " ---g",
|
||||||
|
expectedGridMode: "g-sg",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("auto-switches after manually selecting grid", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// Two screenshares will happen in sequence. There is a screen share that
|
||||||
|
// forces spotlight, then the user manually switches back to grid.
|
||||||
|
hasScreenShares: " n-y-ny",
|
||||||
|
userSelection: " ---g",
|
||||||
|
expectedGridMode: "g-sg-s",
|
||||||
|
// If we did want to respect manual selection, the expectation would be: g-sg
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("switches back to grid mode when the remote screen share ends", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
hasScreenShares: " n--y--n",
|
||||||
|
expectedGridMode: "g--s--g",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("auto-switches to spotlight again after first screen share ends", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
hasScreenShares: " nyny",
|
||||||
|
expectedGridMode: "gsgs",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("switches manually to grid after screen share while manually in spotlight", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// Initially, no one is sharing. Then the user manually switches to spotlight.
|
||||||
|
// After a screen share starts, the user manually switches to grid.
|
||||||
|
hasScreenShares: " n-y",
|
||||||
|
userSelection: " -s-g",
|
||||||
|
expectedGridMode: "gs-g",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("auto-switches to spotlight when in flat window mode", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// First normal, then narrow, then flat.
|
||||||
|
windowMode: " nNf",
|
||||||
|
expectedGridMode: "g-s",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("allows switching modes manually when in flat window mode", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
// Window becomes flat, then user switches to grid and back.
|
||||||
|
// Finally the window returns to a normal shape.
|
||||||
|
windowMode: " nf--n",
|
||||||
|
userSelection: " --gs",
|
||||||
|
expectedGridMode: "gsgsg",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("stays in spotlight while there are screen shares even when window mode changes", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
windowMode: " nfn",
|
||||||
|
hasScreenShares: " y",
|
||||||
|
expectedGridMode: "s",
|
||||||
|
}));
|
||||||
|
|
||||||
|
test("ignores end of screen share until window mode returns to normal", () =>
|
||||||
|
testLayoutSwitch({
|
||||||
|
windowMode: " nf-n",
|
||||||
|
hasScreenShares: " y-n",
|
||||||
|
expectedGridMode: "s--g",
|
||||||
|
}));
|
||||||
93
src/state/CallViewModel/LayoutSwitch.ts
Normal file
93
src/state/CallViewModel/LayoutSwitch.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
combineLatest,
|
||||||
|
map,
|
||||||
|
Subject,
|
||||||
|
startWith,
|
||||||
|
skipWhile,
|
||||||
|
switchMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { type GridMode, type WindowMode } from "./CallViewModel.ts";
|
||||||
|
import { constant, type Behavior } from "../Behavior.ts";
|
||||||
|
import { type ObservableScope } from "../ObservableScope.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a layout mode switch that allows switching between grid and spotlight modes.
|
||||||
|
* The actual layout mode might switch automatically to spotlight if there is a
|
||||||
|
* remote screen share active or if the window mode is flat.
|
||||||
|
*
|
||||||
|
* @param scope - The observable scope to manage subscriptions.
|
||||||
|
* @param windowMode$ - The current window mode.
|
||||||
|
* @param hasRemoteScreenShares$ - A behavior indicating if there are remote screen shares active.
|
||||||
|
*/
|
||||||
|
export function createLayoutModeSwitch(
|
||||||
|
scope: ObservableScope,
|
||||||
|
windowMode$: Behavior<WindowMode>,
|
||||||
|
hasRemoteScreenShares$: Behavior<boolean>,
|
||||||
|
): {
|
||||||
|
gridMode$: Behavior<GridMode>;
|
||||||
|
setGridMode: (value: GridMode) => void;
|
||||||
|
} {
|
||||||
|
const userSelection$ = new Subject<GridMode>();
|
||||||
|
// Callback to set the grid mode desired by the user.
|
||||||
|
// Notice that this is only a preference, the actual grid mode can be overridden
|
||||||
|
// if there is a remote screen share active.
|
||||||
|
const setGridMode = (value: GridMode): void => userSelection$.next(value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The natural grid mode - the mode that the grid would prefer to be in,
|
||||||
|
* not accounting for the user's manual selections.
|
||||||
|
*/
|
||||||
|
const naturalGridMode$ = scope.behavior<GridMode>(
|
||||||
|
combineLatest(
|
||||||
|
[hasRemoteScreenShares$, windowMode$],
|
||||||
|
(hasRemoteScreenShares, windowMode) =>
|
||||||
|
// When there are screen shares or the window is flat (as with a phone
|
||||||
|
// in landscape orientation), spotlight is a better experience.
|
||||||
|
// We want screen shares to be big and readable, and we want flipping
|
||||||
|
// your phone into landscape to be a quick way of maximising the
|
||||||
|
// spotlight tile.
|
||||||
|
hasRemoteScreenShares || windowMode === "flat" ? "spotlight" : "grid",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout mode of the media tile grid.
|
||||||
|
*/
|
||||||
|
const gridMode$ = scope.behavior<GridMode>(
|
||||||
|
// Whenever the user makes a selection, we enter a new mode of behavior:
|
||||||
|
userSelection$.pipe(
|
||||||
|
map((selection) => {
|
||||||
|
if (selection === "grid")
|
||||||
|
// The user has selected grid mode. Start by respecting their choice,
|
||||||
|
// but then follow the natural mode again as soon as it matches.
|
||||||
|
return naturalGridMode$.pipe(
|
||||||
|
skipWhile((naturalMode) => naturalMode !== selection),
|
||||||
|
startWith(selection),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The user has selected spotlight mode. If this matches the natural
|
||||||
|
// mode, then follow the natural mode going forward.
|
||||||
|
return selection === naturalGridMode$.value
|
||||||
|
? naturalGridMode$
|
||||||
|
: constant(selection);
|
||||||
|
}),
|
||||||
|
// Initially the mode of behavior is to just follow the natural grid mode.
|
||||||
|
startWith(naturalGridMode$),
|
||||||
|
// Switch between each mode of behavior.
|
||||||
|
switchMap((mode$) => mode$),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridMode$,
|
||||||
|
setGridMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -97,106 +97,106 @@ describe("createHomeserverConnected$", () => {
|
|||||||
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
// LLM generated test cases. They are a bit overkill but I improved the mocking so it is
|
||||||
// easy enough to read them so I think they can stay.
|
// easy enough to read them so I think they can stay.
|
||||||
it("is false when sync state is not Syncing", () => {
|
it("is false when sync state is not Syncing", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
it("remains false while membership status is not Connected even if sync is Syncing", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected$.value).toBe(false); // membership still disconnected
|
expect(hsConnected.combined$.value).toBe(false); // membership still disconnected
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
it("is false when membership status transitions to Connected but ProbablyLeft is true", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
// Make sync loop OK
|
// Make sync loop OK
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
// Indicate probable leave before connection
|
// Indicate probable leave before connection
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("becomes true only when all three conditions are satisfied", () => {
|
it("becomes true only when all three conditions are satisfied", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
// 1. Sync loop connected
|
// 1. Sync loop connected
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected$.value).toBe(false); // not yet membership connected
|
expect(hsConnected.combined$.value).toBe(false); // not yet membership connected
|
||||||
// 2. Membership connected
|
// 2. Membership connected
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true); // probablyLeft is false
|
expect(hsConnected.combined$.value).toBe(true); // probablyLeft is false
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when sync loop leaves Syncing", () => {
|
it("drops back to false when sync loop leaves Syncing", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
// Reach connected state
|
// Reach connected state
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
// Sync loop error => should flip false
|
// Sync loop error => should flip false
|
||||||
client.setSyncState(SyncState.Error);
|
client.setSyncState(SyncState.Error);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops back to false when membership status becomes disconnected", () => {
|
it("drops back to false when membership status becomes disconnected", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
session.setMembershipStatus(Status.Disconnected);
|
session.setMembershipStatus(Status.Disconnected);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
it("drops to false when ProbablyLeft is emitted after being true", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
it("recovers to true if ProbablyLeft becomes false again while other conditions remain true", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
// Simulate clearing the flag (in realistic scenario membership manager would update)
|
||||||
session.setProbablyLeft(false);
|
session.setProbablyLeft(false);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("composite sequence reflects each individual failure reason", () => {
|
it("composite sequence reflects each individual failure reason", () => {
|
||||||
const hsConnected$ = createHomeserverConnected$(scope, client, session);
|
const hsConnected = createHomeserverConnected$(scope, client, session);
|
||||||
|
|
||||||
// Initially false (sync error + disconnected + not probably left)
|
// Initially false (sync error + disconnected + not probably left)
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Fix sync only
|
// Fix sync only
|
||||||
client.setSyncState(SyncState.Syncing);
|
client.setSyncState(SyncState.Syncing);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Fix membership
|
// Fix membership
|
||||||
session.setMembershipStatus(Status.Connected);
|
session.setMembershipStatus(Status.Connected);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
// Introduce probablyLeft -> false
|
// Introduce probablyLeft -> false
|
||||||
session.setProbablyLeft(true);
|
session.setProbablyLeft(true);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
|
|
||||||
// Restore notProbablyLeft -> true again
|
// Restore notProbablyLeft -> true again
|
||||||
session.setProbablyLeft(false);
|
session.setProbablyLeft(false);
|
||||||
expect(hsConnected$.value).toBe(true);
|
expect(hsConnected.combined$.value).toBe(true);
|
||||||
|
|
||||||
// Drop sync -> false
|
// Drop sync -> false
|
||||||
client.setSyncState(SyncState.Error);
|
client.setSyncState(SyncState.Error);
|
||||||
expect(hsConnected$.value).toBe(false);
|
expect(hsConnected.combined$.value).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import { type NodeStyleEventEmitter } from "../../../utils/test";
|
|||||||
*/
|
*/
|
||||||
const logger = rootLogger.getChild("[HomeserverConnected]");
|
const logger = rootLogger.getChild("[HomeserverConnected]");
|
||||||
|
|
||||||
|
export interface HomeserverConnected {
|
||||||
|
combined$: Behavior<boolean>;
|
||||||
|
rtsSession$: Behavior<Status>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
* Behavior representing whether we consider ourselves connected to the Matrix homeserver
|
||||||
* for the purposes of a MatrixRTC session.
|
* for the purposes of a MatrixRTC session.
|
||||||
@@ -39,7 +44,7 @@ export function createHomeserverConnected$(
|
|||||||
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
client: NodeStyleEventEmitter & Pick<MatrixClient, "getSyncState">,
|
||||||
matrixRTCSession: NodeStyleEventEmitter &
|
matrixRTCSession: NodeStyleEventEmitter &
|
||||||
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
Pick<MatrixRTCSession, "membershipStatus" | "probablyLeft">,
|
||||||
): Behavior<boolean> {
|
): HomeserverConnected {
|
||||||
const syncing$ = (
|
const syncing$ = (
|
||||||
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
fromEvent(client, ClientEvent.Sync) as Observable<[SyncState]>
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -47,12 +52,15 @@ export function createHomeserverConnected$(
|
|||||||
map(([state]) => state === SyncState.Syncing),
|
map(([state]) => state === SyncState.Syncing),
|
||||||
);
|
);
|
||||||
|
|
||||||
const membershipConnected$ = fromEvent(
|
const rtsSession$ = scope.behavior<Status>(
|
||||||
matrixRTCSession,
|
fromEvent(matrixRTCSession, MembershipManagerEvent.StatusChanged).pipe(
|
||||||
MembershipManagerEvent.StatusChanged,
|
map(() => matrixRTCSession.membershipStatus ?? Status.Unknown),
|
||||||
).pipe(
|
),
|
||||||
startWith(null),
|
Status.Unknown,
|
||||||
map(() => matrixRTCSession.membershipStatus === Status.Connected),
|
);
|
||||||
|
|
||||||
|
const membershipConnected$ = rtsSession$.pipe(
|
||||||
|
map((status) => status === Status.Connected),
|
||||||
);
|
);
|
||||||
|
|
||||||
// This is basically notProbablyLeft$
|
// This is basically notProbablyLeft$
|
||||||
@@ -71,15 +79,13 @@ export function createHomeserverConnected$(
|
|||||||
map(() => matrixRTCSession.probablyLeft !== true),
|
map(() => matrixRTCSession.probablyLeft !== true),
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectedCombined$ = and$(
|
const combined$ = scope.behavior(
|
||||||
syncing$,
|
and$(syncing$, membershipConnected$, certainlyConnected$).pipe(
|
||||||
membershipConnected$,
|
tap((connected) => {
|
||||||
certainlyConnected$,
|
logger.info(`Homeserver connected update: ${connected}`);
|
||||||
).pipe(
|
}),
|
||||||
tap((connected) => {
|
),
|
||||||
logger.info(`Homeserver connected update: ${connected}`);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return scope.behavior(connectedCombined$);
|
return { combined$, rtsSession$ };
|
||||||
}
|
}
|
||||||
|
|||||||
527
src/state/CallViewModel/localMember/LocalMember.test.ts
Normal file
527
src/state/CallViewModel/localMember/LocalMember.test.ts
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
Copyright 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 {
|
||||||
|
Status as RTCMemberStatus,
|
||||||
|
type LivekitTransport,
|
||||||
|
type MatrixRTCSession,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||||
|
import { BehaviorSubject, map, of } from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { type LocalParticipant, type LocalTrack } from "livekit-client";
|
||||||
|
|
||||||
|
import { MatrixRTCMode } from "../../../settings/settings";
|
||||||
|
import {
|
||||||
|
flushPromises,
|
||||||
|
mockConfig,
|
||||||
|
mockLivekitRoom,
|
||||||
|
mockMuteStates,
|
||||||
|
withTestScheduler,
|
||||||
|
} from "../../../utils/test";
|
||||||
|
import {
|
||||||
|
TransportState,
|
||||||
|
createLocalMembership$,
|
||||||
|
enterRTCSession,
|
||||||
|
PublishState,
|
||||||
|
TrackState,
|
||||||
|
} from "./LocalMember";
|
||||||
|
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
|
||||||
|
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||||
|
import { constant } from "../../Behavior";
|
||||||
|
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||||
|
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||||
|
import { type Publisher } from "./Publisher";
|
||||||
|
|
||||||
|
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
|
||||||
|
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||||
|
vi.mock("../../../UrlParams", () => ({ getUrlParams }));
|
||||||
|
vi.mock("@livekit/components-core", () => ({
|
||||||
|
observeParticipantEvents: vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue(of({ isScreenShareEnabled: false })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("LocalMembership", () => {
|
||||||
|
describe("enterRTCSession", () => {
|
||||||
|
it("It joins the correct Session", () => {
|
||||||
|
const focusFromOlderMembership = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "http://my-oldest-member-service-url.com",
|
||||||
|
livekit_alias: "my-oldest-member-service-alias",
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusConfigFromWellKnown = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "http://my-well-known-service-url.com",
|
||||||
|
};
|
||||||
|
const focusConfigFromWellKnown2 = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "http://my-well-known-service-url2.com",
|
||||||
|
};
|
||||||
|
const clientWellKnown = {
|
||||||
|
"org.matrix.msc4143.rtc_foci": [
|
||||||
|
focusConfigFromWellKnown,
|
||||||
|
focusConfigFromWellKnown2,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockConfig({
|
||||||
|
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation(
|
||||||
|
async (domain) => {
|
||||||
|
if (domain === "example.org") {
|
||||||
|
return Promise.resolve(clientWellKnown);
|
||||||
|
}
|
||||||
|
return Promise.resolve({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockedSession = vi.mocked({
|
||||||
|
room: {
|
||||||
|
roomId: "roomId",
|
||||||
|
client: {
|
||||||
|
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||||
|
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||||
|
access_token: "ACCCESS_TOKEN",
|
||||||
|
token_type: "Bearer",
|
||||||
|
matrix_server_name: "localhost",
|
||||||
|
expires_in: 10000,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
memberships: [],
|
||||||
|
getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership),
|
||||||
|
getOldestMembership: vi.fn().mockReturnValue({
|
||||||
|
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
|
||||||
|
}),
|
||||||
|
joinRoomSession: vi.fn(),
|
||||||
|
}) as unknown as MatrixRTCSession;
|
||||||
|
|
||||||
|
enterRTCSession(
|
||||||
|
mockedSession,
|
||||||
|
{
|
||||||
|
livekit_alias: "roomId",
|
||||||
|
livekit_service_url: "http://my-well-known-service-url.com",
|
||||||
|
type: "livekit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
encryptMedia: true,
|
||||||
|
matrixRTCMode: MATRIX_RTC_MODE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
livekit_alias: "roomId",
|
||||||
|
livekit_service_url: "http://my-well-known-service-url.com",
|
||||||
|
type: "livekit",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
manageMediaKeys: true,
|
||||||
|
useLegacyMemberEvents: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", () => {
|
||||||
|
mockConfig({});
|
||||||
|
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
|
||||||
|
"org.matrix.msc4143.rtc_foci": [
|
||||||
|
{
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: "http://my-well-known-service-url.com",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockedSession = vi.mocked({
|
||||||
|
room: {
|
||||||
|
roomId: "roomId",
|
||||||
|
client: {
|
||||||
|
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||||
|
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||||
|
access_token: "ACCCESS_TOKEN",
|
||||||
|
token_type: "Bearer",
|
||||||
|
matrix_server_name: "localhost",
|
||||||
|
expires_in: 10000,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
memberships: [],
|
||||||
|
getFocusInUse: vi.fn(),
|
||||||
|
joinRoomSession: vi.fn(),
|
||||||
|
}) as unknown as MatrixRTCSession;
|
||||||
|
|
||||||
|
enterRTCSession(
|
||||||
|
mockedSession,
|
||||||
|
{
|
||||||
|
livekit_alias: "roomId",
|
||||||
|
livekit_service_url: "http://my-well-known-service-url.com",
|
||||||
|
type: "livekit",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
encryptMedia: true,
|
||||||
|
matrixRTCMode: MATRIX_RTC_MODE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultCreateLocalMemberValues = {
|
||||||
|
options: constant({
|
||||||
|
encryptMedia: false,
|
||||||
|
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
||||||
|
}),
|
||||||
|
matrixRTCSession: {
|
||||||
|
updateCallIntent: () => {},
|
||||||
|
leaveRoomSession: () => {},
|
||||||
|
} as unknown as MatrixRTCSession,
|
||||||
|
muteStates: mockMuteStates(),
|
||||||
|
trackProcessorState$: constant({
|
||||||
|
supported: false,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
logger: logger,
|
||||||
|
createPublisherFactory: vi.fn(),
|
||||||
|
joinMatrixRTC: async (): Promise<void> => {},
|
||||||
|
homeserverConnected: {
|
||||||
|
combined$: constant(true),
|
||||||
|
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("throws error on missing RTC config error", () => {
|
||||||
|
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||||
|
const localTransport$ = scope.behavior<null | LivekitTransport>(
|
||||||
|
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// we do not need any connection data since we want to fail before reaching that.
|
||||||
|
const mockConnectionManager = {
|
||||||
|
transports$: scope.behavior(
|
||||||
|
localTransport$.pipe(map((t) => new Epoch([t]))),
|
||||||
|
),
|
||||||
|
connectionManagerData$: constant(
|
||||||
|
new Epoch(new ConnectionManagerData()),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const localMembership = createLocalMembership$({
|
||||||
|
scope,
|
||||||
|
...defaultCreateLocalMemberValues,
|
||||||
|
connectionManager: mockConnectionManager,
|
||||||
|
localTransport$,
|
||||||
|
});
|
||||||
|
localMembership.requestJoinAndPublish();
|
||||||
|
|
||||||
|
expectObservable(localMembership.localMemberState$).toBe("ne", {
|
||||||
|
n: TransportState.Waiting,
|
||||||
|
e: expect.toSatisfy((e) => e instanceof MatrixRTCTransportMissingError),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const aTransport = {
|
||||||
|
livekit_service_url: "a",
|
||||||
|
} as LivekitTransport;
|
||||||
|
const bTransport = {
|
||||||
|
livekit_service_url: "b",
|
||||||
|
} as LivekitTransport;
|
||||||
|
|
||||||
|
const connectionTransportAConnected = {
|
||||||
|
livekitRoom: mockLivekitRoom({
|
||||||
|
localParticipant: {
|
||||||
|
isScreenShareEnabled: false,
|
||||||
|
trackPublications: [],
|
||||||
|
} as unknown as LocalParticipant,
|
||||||
|
}),
|
||||||
|
state$: constant(ConnectionState.LivekitConnected),
|
||||||
|
transport: aTransport,
|
||||||
|
} as unknown as Connection;
|
||||||
|
const connectionTransportAConnecting = {
|
||||||
|
...connectionTransportAConnected,
|
||||||
|
state$: constant(ConnectionState.LivekitConnecting),
|
||||||
|
livekitRoom: mockLivekitRoom({}),
|
||||||
|
} as unknown as Connection;
|
||||||
|
const connectionTransportBConnected = {
|
||||||
|
state$: constant(ConnectionState.LivekitConnected),
|
||||||
|
transport: bTransport,
|
||||||
|
livekitRoom: mockLivekitRoom({}),
|
||||||
|
} as unknown as Connection;
|
||||||
|
|
||||||
|
it("recreates publisher if new connection is used and ENDS always unpublish and end tracks", async () => {
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
|
const localTransport$ = new BehaviorSubject(aTransport);
|
||||||
|
|
||||||
|
const publishers: Publisher[] = [];
|
||||||
|
let seed = 0;
|
||||||
|
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||||
|
() => {
|
||||||
|
const a = seed;
|
||||||
|
seed += 1;
|
||||||
|
logger.info(`creating [${a}]`);
|
||||||
|
const p = {
|
||||||
|
stopPublishing: vi.fn().mockImplementation(() => {
|
||||||
|
logger.info(`stopPublishing [${a}]`);
|
||||||
|
}),
|
||||||
|
stopTracks: vi.fn(),
|
||||||
|
};
|
||||||
|
publishers.push(p as unknown as Publisher);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const publisherFactory =
|
||||||
|
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
const connectionManagerData = new ConnectionManagerData();
|
||||||
|
connectionManagerData.add(connectionTransportAConnected, []);
|
||||||
|
connectionManagerData.add(connectionTransportBConnected, []);
|
||||||
|
createLocalMembership$({
|
||||||
|
scope,
|
||||||
|
...defaultCreateLocalMemberValues,
|
||||||
|
connectionManager: {
|
||||||
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
|
},
|
||||||
|
localTransport$,
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
localTransport$.next(bTransport);
|
||||||
|
await flushPromises();
|
||||||
|
expect(publisherFactory).toHaveBeenCalledTimes(2);
|
||||||
|
expect(publishers.length).toBe(2);
|
||||||
|
// stop the first Publisher and let the second one life.
|
||||||
|
expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||||
|
expect(publishers[1].stopTracks).not.toHaveBeenCalled();
|
||||||
|
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||||
|
expect(publishers[1].stopPublishing).not.toHaveBeenCalled();
|
||||||
|
expect(publisherFactory.mock.calls[0][0].transport).toBe(aTransport);
|
||||||
|
expect(publisherFactory.mock.calls[1][0].transport).toBe(bTransport);
|
||||||
|
scope.end();
|
||||||
|
await flushPromises();
|
||||||
|
// stop all tracks after ending scopes
|
||||||
|
expect(publishers[1].stopPublishing).toHaveBeenCalled();
|
||||||
|
// expect(publishers[1].stopTracks).toHaveBeenCalled();
|
||||||
|
|
||||||
|
defaultCreateLocalMemberValues.createPublisherFactory.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only start tracks if requested", async () => {
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
|
const localTransport$ = new BehaviorSubject(aTransport);
|
||||||
|
|
||||||
|
const publishers: Publisher[] = [];
|
||||||
|
|
||||||
|
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
||||||
|
const publishing$ = new BehaviorSubject<boolean>(false);
|
||||||
|
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||||
|
() => {
|
||||||
|
const p = {
|
||||||
|
stopPublishing: vi.fn(),
|
||||||
|
stopTracks: vi.fn(),
|
||||||
|
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||||
|
tracks$.next([{}, {}] as LocalTrack[]);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
tracks$,
|
||||||
|
publishing$,
|
||||||
|
};
|
||||||
|
publishers.push(p as unknown as Publisher);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const publisherFactory =
|
||||||
|
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
const connectionManagerData = new ConnectionManagerData();
|
||||||
|
connectionManagerData.add(connectionTransportAConnected, []);
|
||||||
|
// connectionManagerData.add(connectionTransportB, []);
|
||||||
|
const localMembership = createLocalMembership$({
|
||||||
|
scope,
|
||||||
|
...defaultCreateLocalMemberValues,
|
||||||
|
connectionManager: {
|
||||||
|
connectionManagerData$: constant(new Epoch(connectionManagerData)),
|
||||||
|
},
|
||||||
|
localTransport$,
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||||
|
// expect(localMembership.tracks$.value.length).toBe(0);
|
||||||
|
expect(publishers[0].createAndSetupTracks).not.toHaveBeenCalled();
|
||||||
|
localMembership.startTracks();
|
||||||
|
await flushPromises();
|
||||||
|
expect(publishers[0].createAndSetupTracks).toHaveBeenCalled();
|
||||||
|
// expect(localMembership.tracks$.value.length).toBe(2);
|
||||||
|
scope.end();
|
||||||
|
await flushPromises();
|
||||||
|
// stop all tracks after ending scopes
|
||||||
|
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||||
|
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||||
|
publisherFactory.mockClear();
|
||||||
|
});
|
||||||
|
// TODO add an integration test combining publisher and localMembership
|
||||||
|
//
|
||||||
|
it("tracks livekit state correctly", async () => {
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
|
const connectionManagerData = new ConnectionManagerData();
|
||||||
|
const localTransport$ = new BehaviorSubject<null | LivekitTransport>(null);
|
||||||
|
const connectionManagerData$ = new BehaviorSubject(
|
||||||
|
new Epoch(connectionManagerData),
|
||||||
|
);
|
||||||
|
const publishers: Publisher[] = [];
|
||||||
|
|
||||||
|
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
||||||
|
const publishing$ = new BehaviorSubject<boolean>(false);
|
||||||
|
const createTrackResolver = Promise.withResolvers<void>();
|
||||||
|
const publishResolver = Promise.withResolvers<void>();
|
||||||
|
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
|
||||||
|
() => {
|
||||||
|
const p = {
|
||||||
|
stopPublishing: vi.fn(),
|
||||||
|
stopTracks: vi.fn().mockImplementation(() => {
|
||||||
|
logger.info("stopTracks");
|
||||||
|
tracks$.next([]);
|
||||||
|
}),
|
||||||
|
createAndSetupTracks: vi.fn().mockImplementation(async () => {
|
||||||
|
await createTrackResolver.promise;
|
||||||
|
tracks$.next([{}, {}] as LocalTrack[]);
|
||||||
|
}),
|
||||||
|
startPublishing: vi.fn().mockImplementation(async () => {
|
||||||
|
await publishResolver.promise;
|
||||||
|
publishing$.next(true);
|
||||||
|
}),
|
||||||
|
tracks$,
|
||||||
|
publishing$,
|
||||||
|
};
|
||||||
|
publishers.push(p as unknown as Publisher);
|
||||||
|
return p;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const publisherFactory =
|
||||||
|
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
const localMembership = createLocalMembership$({
|
||||||
|
scope,
|
||||||
|
...defaultCreateLocalMemberValues,
|
||||||
|
connectionManager: {
|
||||||
|
connectionManagerData$,
|
||||||
|
},
|
||||||
|
localTransport$,
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
expect(localMembership.localMemberState$.value).toStrictEqual(
|
||||||
|
TransportState.Waiting,
|
||||||
|
);
|
||||||
|
localTransport$.next(aTransport);
|
||||||
|
await flushPromises();
|
||||||
|
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||||
|
matrix: RTCMemberStatus.Connected,
|
||||||
|
media: { connection: null, tracks: TrackState.WaitingForUser },
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectionManagerData2 = new ConnectionManagerData();
|
||||||
|
connectionManagerData2.add(
|
||||||
|
// clone because we will mutate this later.
|
||||||
|
{ ...connectionTransportAConnecting } as unknown as Connection,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
connectionManagerData$.next(new Epoch(connectionManagerData2));
|
||||||
|
await flushPromises();
|
||||||
|
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||||
|
matrix: RTCMemberStatus.Connected,
|
||||||
|
media: {
|
||||||
|
connection: ConnectionState.LivekitConnecting,
|
||||||
|
tracks: TrackState.WaitingForUser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(
|
||||||
|
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||||
|
.state$ as BehaviorSubject<ConnectionState>
|
||||||
|
).next(ConnectionState.LivekitConnected);
|
||||||
|
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||||
|
matrix: RTCMemberStatus.Connected,
|
||||||
|
media: {
|
||||||
|
connection: ConnectionState.LivekitConnected,
|
||||||
|
tracks: TrackState.WaitingForUser,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(publisherFactory).toHaveBeenCalledOnce();
|
||||||
|
// expect(localMembership.tracks$.value.length).toBe(0);
|
||||||
|
|
||||||
|
// -------
|
||||||
|
localMembership.startTracks();
|
||||||
|
// -------
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
// expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||||
|
// matrix: RTCMemberStatus.Connected,
|
||||||
|
// media: {
|
||||||
|
// tracks: TrackState.Creating,
|
||||||
|
// connection: ConnectionState.LivekitConnected,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
createTrackResolver.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(localMembership.localMemberState$.value as any).media,
|
||||||
|
).toStrictEqual(PublishState.WaitingForUser);
|
||||||
|
|
||||||
|
// -------
|
||||||
|
localMembership.requestJoinAndPublish();
|
||||||
|
// -------
|
||||||
|
|
||||||
|
expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(localMembership.localMemberState$.value as any).media,
|
||||||
|
).toStrictEqual(PublishState.Publishing);
|
||||||
|
|
||||||
|
publishResolver.resolve();
|
||||||
|
await flushPromises();
|
||||||
|
expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(localMembership.localMemberState$.value as any).media,
|
||||||
|
).toStrictEqual(PublishState.Publishing);
|
||||||
|
|
||||||
|
expect(publishers[0].stopPublishing).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(localMembership.localMemberState$.isStopped).toBe(false);
|
||||||
|
scope.end();
|
||||||
|
await flushPromises();
|
||||||
|
// stays in connected state because it is stopped before the update to tracks update the state.
|
||||||
|
expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(localMembership.localMemberState$.value as any).media,
|
||||||
|
).toStrictEqual(PublishState.Publishing);
|
||||||
|
// stop all tracks after ending scopes
|
||||||
|
expect(publishers[0].stopPublishing).toHaveBeenCalled();
|
||||||
|
// expect(publishers[0].stopTracks).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
// TODO add tests for matrix local matrix participation.
|
||||||
|
});
|
||||||
739
src/state/CallViewModel/localMember/LocalMember.ts
Normal file
739
src/state/CallViewModel/localMember/LocalMember.ts
Normal file
@@ -0,0 +1,739 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Participant,
|
||||||
|
ParticipantEvent,
|
||||||
|
type LocalParticipant,
|
||||||
|
type ScreenShareCaptureOptions,
|
||||||
|
RoomEvent,
|
||||||
|
MediaDeviceFailure,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { observeParticipantEvents } from "@livekit/components-core";
|
||||||
|
import {
|
||||||
|
Status as RTCSessionStatus,
|
||||||
|
type LivekitTransport,
|
||||||
|
type MatrixRTCSession,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
from,
|
||||||
|
fromEvent,
|
||||||
|
map,
|
||||||
|
type Observable,
|
||||||
|
of,
|
||||||
|
pairwise,
|
||||||
|
startWith,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
} from "rxjs";
|
||||||
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||||
|
|
||||||
|
import { type Behavior } from "../../Behavior.ts";
|
||||||
|
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
|
||||||
|
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||||
|
import { type Publisher } from "./Publisher.ts";
|
||||||
|
import { type MuteStates } from "../../MuteStates.ts";
|
||||||
|
import {
|
||||||
|
ElementCallError,
|
||||||
|
FailToStartLivekitConnection,
|
||||||
|
MembershipManagerError,
|
||||||
|
UnknownCallError,
|
||||||
|
} from "../../../utils/errors.ts";
|
||||||
|
import { ElementWidgetActions, widget } from "../../../widget.ts";
|
||||||
|
import { getUrlParams } from "../../../UrlParams.ts";
|
||||||
|
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||||
|
import { MatrixRTCMode } from "../../../settings/settings.ts";
|
||||||
|
import { Config } from "../../../config/Config.ts";
|
||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
type Connection,
|
||||||
|
type FailedToStartError,
|
||||||
|
} from "../remoteMembers/Connection.ts";
|
||||||
|
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||||
|
import { and$ } from "../../../utils/observable.ts";
|
||||||
|
|
||||||
|
export enum TransportState {
|
||||||
|
/** Not even a transport is available to the LocalMembership */
|
||||||
|
Waiting = "transport_waiting",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PublishState {
|
||||||
|
WaitingForUser = "publish_waiting_for_user",
|
||||||
|
// XXX: This state is removed for now since we do not have full control over
|
||||||
|
// track publication anymore with the publisher abstraction, might come back in the future?
|
||||||
|
// /** Implies lk connection is connected */
|
||||||
|
// Starting = "publish_start_publishing",
|
||||||
|
/** Implies lk connection is connected */
|
||||||
|
Publishing = "publish_publishing",
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO not sure how to map that correctly with the
|
||||||
|
// new publisher that does not manage tracks itself anymore
|
||||||
|
export enum TrackState {
|
||||||
|
/** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */
|
||||||
|
WaitingForUser = "tracks_waiting_for_user",
|
||||||
|
// XXX: This state is removed for now since we do not have full control over
|
||||||
|
// track creation anymore with the publisher abstraction, might come back in the future?
|
||||||
|
// /** Implies lk connection is connected */
|
||||||
|
// Creating = "tracks_creating",
|
||||||
|
/** Implies lk connection is connected */
|
||||||
|
Ready = "tracks_ready",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalMemberMediaState =
|
||||||
|
| {
|
||||||
|
tracks: TrackState;
|
||||||
|
connection: ConnectionState | FailedToStartError;
|
||||||
|
}
|
||||||
|
| PublishState
|
||||||
|
| ElementCallError;
|
||||||
|
export type LocalMemberState =
|
||||||
|
| ElementCallError
|
||||||
|
| TransportState.Waiting
|
||||||
|
| {
|
||||||
|
media: LocalMemberMediaState;
|
||||||
|
matrix: ElementCallError | RTCSessionStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* - get well known
|
||||||
|
* - get oldest membership
|
||||||
|
* - get transport to use
|
||||||
|
* - get openId + jwt token
|
||||||
|
* - wait for createTrack() call
|
||||||
|
* - create tracks
|
||||||
|
* - wait for join() call
|
||||||
|
* - Publisher.publishTracks()
|
||||||
|
* - send join state/sticky event
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// TODO add a comment into some code style readme or file header callviewmodel
|
||||||
|
// that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes
|
||||||
|
scope: ObservableScope;
|
||||||
|
muteStates: MuteStates;
|
||||||
|
connectionManager: IConnectionManager;
|
||||||
|
createPublisherFactory: (connection: Connection) => Publisher;
|
||||||
|
joinMatrixRTC: (transport: LivekitTransport) => void;
|
||||||
|
homeserverConnected: HomeserverConnected;
|
||||||
|
localTransport$: Behavior<LivekitTransport | null>;
|
||||||
|
matrixRTCSession: Pick<
|
||||||
|
MatrixRTCSession,
|
||||||
|
"updateCallIntent" | "leaveRoomSession"
|
||||||
|
>;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is responsible for managing the own membership in a room.
|
||||||
|
* We want
|
||||||
|
* - a publisher
|
||||||
|
* -
|
||||||
|
* @param props The properties required to create the local membership.
|
||||||
|
* @param props.scope The observable scope to use.
|
||||||
|
* @param props.connectionManager The connection manager to get connections from.
|
||||||
|
* @param props.createPublisherFactory Factory to create a publisher once we have a connection.
|
||||||
|
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
|
||||||
|
* @param props.homeserverConnected The homeserver connected state.
|
||||||
|
* @param props.localTransport$ The local transport to use for publishing.
|
||||||
|
* @param props.logger The logger to use.
|
||||||
|
* @param props.muteStates The mute states for video and audio.
|
||||||
|
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||||
|
* @returns
|
||||||
|
* - publisher: The handle to create tracks and publish them to the room.
|
||||||
|
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
|
||||||
|
* - transport$: the transport object the ownMembership$ ended up using.
|
||||||
|
* - connectionState: the current connection state. Including matrix server and livekit server connection.
|
||||||
|
* - sharingScreen$: Whether we are sharing our screen. `undefined` if we cannot share the screen.
|
||||||
|
*/
|
||||||
|
export const createLocalMembership$ = ({
|
||||||
|
scope,
|
||||||
|
connectionManager,
|
||||||
|
localTransport$: localTransportCanThrow$,
|
||||||
|
homeserverConnected,
|
||||||
|
createPublisherFactory,
|
||||||
|
joinMatrixRTC,
|
||||||
|
logger: parentLogger,
|
||||||
|
muteStates,
|
||||||
|
matrixRTCSession,
|
||||||
|
}: Props): {
|
||||||
|
/**
|
||||||
|
* This request to start audio and video tracks.
|
||||||
|
* Can be called early to pre-emptively get media permissions and start devices.
|
||||||
|
*/
|
||||||
|
startTracks: () => void;
|
||||||
|
/**
|
||||||
|
* This sets a inner state (shouldPublish) to true and instructs the js-sdk and livekit to keep the user
|
||||||
|
* connected to matrix and livekit.
|
||||||
|
*/
|
||||||
|
requestJoinAndPublish: () => void;
|
||||||
|
requestDisconnect: () => void;
|
||||||
|
localMemberState$: Behavior<LocalMemberState>;
|
||||||
|
sharingScreen$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||||
|
*/
|
||||||
|
toggleScreenSharing: (() => void) | null;
|
||||||
|
// tracks$: Behavior<LocalTrack[]>;
|
||||||
|
participant$: Behavior<LocalParticipant | null>;
|
||||||
|
connection$: Behavior<Connection | null>;
|
||||||
|
/**
|
||||||
|
* Tracks the homserver and livekit connected state and based on that computes reconnecting.
|
||||||
|
*/
|
||||||
|
reconnecting$: Behavior<boolean>;
|
||||||
|
/** Shorthand for homeserverConnected.rtcSession === Status.Disconnected
|
||||||
|
* Direct translation to the js-sdk membership manager connection `Status`.
|
||||||
|
*/
|
||||||
|
disconnected$: Behavior<boolean>;
|
||||||
|
/**
|
||||||
|
* Fully connected
|
||||||
|
*/
|
||||||
|
connected$: Behavior<boolean>;
|
||||||
|
} => {
|
||||||
|
const logger = parentLogger.getChild("[LocalMembership]");
|
||||||
|
logger.debug(`Creating local membership..`);
|
||||||
|
|
||||||
|
// Unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
|
||||||
|
const localTransport$ = scope.behavior(
|
||||||
|
localTransportCanThrow$.pipe(
|
||||||
|
catchError((e: unknown) => {
|
||||||
|
let error: ElementCallError;
|
||||||
|
if (e instanceof ElementCallError) {
|
||||||
|
error = e;
|
||||||
|
} else {
|
||||||
|
error = new UnknownCallError(
|
||||||
|
e instanceof Error
|
||||||
|
? e
|
||||||
|
: new Error("Unknown error from localTransport"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setTransportError(error);
|
||||||
|
return of(null);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop Epoch data here since we will not combine this anymore
|
||||||
|
const localConnection$ = scope.behavior(
|
||||||
|
combineLatest([
|
||||||
|
connectionManager.connectionManagerData$,
|
||||||
|
localTransport$,
|
||||||
|
]).pipe(
|
||||||
|
map(([{ value: connectionData }, localTransport]) => {
|
||||||
|
if (localTransport === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectionData.getConnectionForTransport(localTransport);
|
||||||
|
}),
|
||||||
|
tap((connection) => {
|
||||||
|
logger.info(
|
||||||
|
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tracks error that happen when creating the local tracks.
|
||||||
|
const mediaErrors$ = localConnection$.pipe(
|
||||||
|
switchMap((connection) => {
|
||||||
|
if (!connection) {
|
||||||
|
return of(null);
|
||||||
|
} else {
|
||||||
|
return fromEvent(
|
||||||
|
connection.livekitRoom,
|
||||||
|
RoomEvent.MediaDevicesError,
|
||||||
|
(error: Error) => {
|
||||||
|
return MediaDeviceFailure.getFailure(error) ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mediaErrors$.pipe(scope.bind()).subscribe((error) => {
|
||||||
|
if (error) {
|
||||||
|
logger.error(`Failed to create local tracks:`, error);
|
||||||
|
setMatrixError(
|
||||||
|
// TODO is it fatal? Do we need to create a new Specialized Error?
|
||||||
|
new UnknownCallError(new Error(`Media device error: ${error}`)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// MATRIX RELATED
|
||||||
|
|
||||||
|
// This should be used in a combineLatest with publisher$ to connect.
|
||||||
|
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
||||||
|
const trackStartRequested = Promise.withResolvers<void>();
|
||||||
|
|
||||||
|
// This should be used in a combineLatest with publisher$ to connect.
|
||||||
|
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
||||||
|
const joinAndPublishRequested$ = new BehaviorSubject(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The publisher is stored in here an abstracts creating and publishing tracks.
|
||||||
|
*/
|
||||||
|
const publisher$ = new BehaviorSubject<Publisher | null>(null);
|
||||||
|
|
||||||
|
const startTracks = (): void => {
|
||||||
|
trackStartRequested.resolve();
|
||||||
|
// This used to return the tracks, but now they are only accessible via the publisher.
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestJoinAndPublish = (): void => {
|
||||||
|
trackStartRequested.resolve();
|
||||||
|
joinAndPublishRequested$.next(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestDisconnect = (): void => {
|
||||||
|
joinAndPublishRequested$.next(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Take care of the publisher$
|
||||||
|
// create a new one as soon as a local Connection is available
|
||||||
|
//
|
||||||
|
// Recreate a new one once the local connection changes
|
||||||
|
// - stop publishing
|
||||||
|
// - destruct all current streams
|
||||||
|
// - overwrite current publisher
|
||||||
|
scope.reconcile(localConnection$, async (connection) => {
|
||||||
|
if (connection !== null) {
|
||||||
|
const publisher = createPublisherFactory(connection);
|
||||||
|
publisher$.next(publisher);
|
||||||
|
// Clean-up callback
|
||||||
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
|
await publisher.stopPublishing();
|
||||||
|
await publisher.stopTracks();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use reconcile here to not run concurrent createAndSetupTracks calls
|
||||||
|
// `tracks$` will update once they are ready.
|
||||||
|
scope.reconcile(
|
||||||
|
scope.behavior(
|
||||||
|
combineLatest([
|
||||||
|
publisher$ /*, tracks$*/,
|
||||||
|
from(trackStartRequested.promise),
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
async (valueIfReady) => {
|
||||||
|
if (!valueIfReady) return;
|
||||||
|
const [publisher] = valueIfReady;
|
||||||
|
if (publisher) {
|
||||||
|
await publisher.createAndSetupTracks().catch((e) => logger.error(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Based on `connectRequested$` we start publishing tracks. (once they are there!)
|
||||||
|
scope.reconcile(
|
||||||
|
scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])),
|
||||||
|
async ([publisher, shouldJoinAndPublish]) => {
|
||||||
|
// Get the current publishing state to avoid redundant calls.
|
||||||
|
const isPublishing = publisher?.shouldPublish === true;
|
||||||
|
if (shouldJoinAndPublish && !isPublishing) {
|
||||||
|
try {
|
||||||
|
await publisher?.startPublishing();
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
setPublishError(new FailToStartLivekitConnection(message));
|
||||||
|
}
|
||||||
|
} else if (isPublishing) {
|
||||||
|
try {
|
||||||
|
await publisher?.stopPublishing();
|
||||||
|
} catch (error) {
|
||||||
|
setPublishError(new UnknownCallError(error as Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// STATE COMPUTATION
|
||||||
|
|
||||||
|
// These are non fatal since we can join a room and concume media even though publishing failed.
|
||||||
|
const publishError$ = new BehaviorSubject<ElementCallError | null>(null);
|
||||||
|
const setPublishError = (e: ElementCallError): void => {
|
||||||
|
if (publishError$.value !== null) {
|
||||||
|
logger.error("Multiple Media Errors:", e);
|
||||||
|
} else {
|
||||||
|
publishError$.next(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fatalTransportError$ = new BehaviorSubject<ElementCallError | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const setTransportError = (e: ElementCallError): void => {
|
||||||
|
if (fatalTransportError$.value !== null) {
|
||||||
|
logger.error("Multiple Transport Errors:", e);
|
||||||
|
} else {
|
||||||
|
fatalTransportError$.next(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const localConnectionState$ = localConnection$.pipe(
|
||||||
|
switchMap((connection) => (connection ? connection.state$ : of(null))),
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
|
||||||
|
combineLatest([
|
||||||
|
localConnectionState$,
|
||||||
|
localTransport$,
|
||||||
|
joinAndPublishRequested$,
|
||||||
|
from(trackStartRequested.promise).pipe(
|
||||||
|
map(() => true),
|
||||||
|
startWith(false),
|
||||||
|
),
|
||||||
|
]).pipe(
|
||||||
|
map(
|
||||||
|
([
|
||||||
|
localConnectionState,
|
||||||
|
localTransport,
|
||||||
|
shouldPublish,
|
||||||
|
shouldStartTracks,
|
||||||
|
]) => {
|
||||||
|
if (!localTransport) return null;
|
||||||
|
const trackState: TrackState = shouldStartTracks
|
||||||
|
? TrackState.Ready
|
||||||
|
: TrackState.WaitingForUser;
|
||||||
|
|
||||||
|
if (
|
||||||
|
localConnectionState !== ConnectionState.LivekitConnected ||
|
||||||
|
trackState !== TrackState.Ready
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
connection: localConnectionState,
|
||||||
|
tracks: trackState,
|
||||||
|
};
|
||||||
|
if (!shouldPublish) return PublishState.WaitingForUser;
|
||||||
|
// if (!publishing) return PublishState.Starting;
|
||||||
|
return PublishState.Publishing;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fatalMatrixError$ = new BehaviorSubject<ElementCallError | null>(null);
|
||||||
|
const setMatrixError = (e: ElementCallError): void => {
|
||||||
|
if (fatalMatrixError$.value !== null) {
|
||||||
|
logger.error("Multiple Matrix Errors:", e);
|
||||||
|
} else {
|
||||||
|
fatalMatrixError$.next(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const localMemberState$ = scope.behavior<LocalMemberState>(
|
||||||
|
combineLatest([
|
||||||
|
mediaState$,
|
||||||
|
homeserverConnected.rtsSession$,
|
||||||
|
fatalMatrixError$,
|
||||||
|
fatalTransportError$,
|
||||||
|
publishError$,
|
||||||
|
]).pipe(
|
||||||
|
map(
|
||||||
|
([
|
||||||
|
mediaState,
|
||||||
|
rtcSessionStatus,
|
||||||
|
fatalMatrixError,
|
||||||
|
fatalTransportError,
|
||||||
|
publishError,
|
||||||
|
]) => {
|
||||||
|
if (fatalTransportError !== null) return fatalTransportError;
|
||||||
|
// `mediaState` will be 'null' until the transport/connection appears.
|
||||||
|
if (mediaState && rtcSessionStatus)
|
||||||
|
return {
|
||||||
|
matrix: fatalMatrixError ?? rtcSessionStatus,
|
||||||
|
media: publishError ?? mediaState,
|
||||||
|
};
|
||||||
|
return TransportState.Waiting;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we are "fully" connected to the call. Accounts for both the
|
||||||
|
* connection to the MatrixRTC session and the LiveKit publish connection.
|
||||||
|
*/
|
||||||
|
const matrixAndLivekitConnected$ = scope.behavior(
|
||||||
|
and$(
|
||||||
|
homeserverConnected.combined$,
|
||||||
|
localConnectionState$.pipe(
|
||||||
|
map((state) => state === ConnectionState.LivekitConnected),
|
||||||
|
),
|
||||||
|
).pipe(
|
||||||
|
tap((v) => logger.debug("livekit+matrix: Connected state changed", v)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether we should tell the user that we're reconnecting to the call.
|
||||||
|
*/
|
||||||
|
const reconnecting$ = scope.behavior(
|
||||||
|
matrixAndLivekitConnected$.pipe(
|
||||||
|
pairwise(),
|
||||||
|
map(([prev, current]) => prev === true && current === false),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// inform the widget about the connect and disconnect intent from the user.
|
||||||
|
scope
|
||||||
|
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
|
||||||
|
undefined,
|
||||||
|
joinAndPublishRequested$.value,
|
||||||
|
])
|
||||||
|
.subscribe(([prev, current]) => {
|
||||||
|
if (!widget) return;
|
||||||
|
// JOIN prev=false (was left) => current-true (now joiend)
|
||||||
|
if (!prev && current) {
|
||||||
|
widget.api.transport
|
||||||
|
.send(ElementWidgetActions.JoinCall, {})
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error("Failed to send join action", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// LEAVE prev=false (was joined) => current-true (now left)
|
||||||
|
if (prev && !current) {
|
||||||
|
widget.api.transport
|
||||||
|
.send(ElementWidgetActions.HangupCall, {})
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error("Failed to send hangup action", e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
combineLatest([muteStates.video.enabled$, homeserverConnected.combined$])
|
||||||
|
.pipe(scope.bind())
|
||||||
|
.subscribe(([videoEnabled, connected]) => {
|
||||||
|
if (!connected) return;
|
||||||
|
void matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep matrix rtc session in sync with localTransport$, connectRequested$
|
||||||
|
scope.reconcile(
|
||||||
|
scope.behavior(combineLatest([localTransport$, joinAndPublishRequested$])),
|
||||||
|
async ([transport, shouldConnect]) => {
|
||||||
|
if (!transport) return;
|
||||||
|
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
|
||||||
|
if (!shouldConnect) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
joinMatrixRTC(transport);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error entering RTC session", error);
|
||||||
|
if (error instanceof Error)
|
||||||
|
setMatrixError(new MembershipManagerError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
|
||||||
|
await matrixRTCSession.leaveRoomSession(1000);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error leaving RTC session", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const participant$ = scope.behavior(
|
||||||
|
localConnection$.pipe(map((c) => c?.livekitRoom?.localParticipant ?? null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pause upstream of all local media tracks when we're disconnected from
|
||||||
|
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
||||||
|
// 'reconnecting' and yet still be transmitting your media to others.
|
||||||
|
// We use matrixConnected$ rather than reconnecting$ because we want to
|
||||||
|
// pause tracks during the initial joining sequence too until we're sure
|
||||||
|
// that our own media is displayed on screen.
|
||||||
|
// TODO refactor this based no livekitState$
|
||||||
|
combineLatest([participant$, homeserverConnected.combined$])
|
||||||
|
.pipe(scope.bind())
|
||||||
|
.subscribe(([participant, connected]) => {
|
||||||
|
if (!participant) return;
|
||||||
|
const publications = participant.trackPublications.values();
|
||||||
|
if (connected) {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === true) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.info(
|
||||||
|
`Resuming ${kind} track (MatrixRTC connection present)`,
|
||||||
|
);
|
||||||
|
p.track
|
||||||
|
.resumeUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to resume ${kind} track after MatrixRTC reconnection`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const p of publications) {
|
||||||
|
if (p.track?.isUpstreamPaused === false) {
|
||||||
|
const kind = p.track.kind;
|
||||||
|
logger.info(
|
||||||
|
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
|
||||||
|
);
|
||||||
|
p.track
|
||||||
|
.pauseUpstream()
|
||||||
|
.catch((e) =>
|
||||||
|
logger.error(
|
||||||
|
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user is currently sharing their screen.
|
||||||
|
*/
|
||||||
|
const sharingScreen$ = scope.behavior(
|
||||||
|
participant$.pipe(
|
||||||
|
switchMap((p) => (p !== null ? observeSharingScreen$(p) : of(false))),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let toggleScreenSharing: (() => void) | null = null;
|
||||||
|
if (
|
||||||
|
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
|
||||||
|
!getUrlParams().hideScreensharing
|
||||||
|
) {
|
||||||
|
toggleScreenSharing = (): void => {
|
||||||
|
const screenshareSettings: ScreenShareCaptureOptions = {
|
||||||
|
audio: true,
|
||||||
|
selfBrowserSurface: "include",
|
||||||
|
surfaceSwitching: "include",
|
||||||
|
systemAudio: "include",
|
||||||
|
};
|
||||||
|
const targetScreenshareState = !sharingScreen$.value;
|
||||||
|
logger.info(
|
||||||
|
`toggleScreenSharing called. Switching ${
|
||||||
|
targetScreenshareState ? "On" : "Off"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
// If a connection is ready, toggle screen sharing.
|
||||||
|
// We deliberately do nothing in the case of a null connection because
|
||||||
|
// it looks nice for the call control buttons to all become available
|
||||||
|
// at once upon joining the call, rather than introducing a disabled
|
||||||
|
// state. The user can just click again.
|
||||||
|
// We also allow screen sharing to be toggled even if the connection
|
||||||
|
// is still initializing or publishing tracks, because there's no
|
||||||
|
// technical reason to disallow this. LiveKit will publish if it can.
|
||||||
|
participant$.value
|
||||||
|
?.setScreenShareEnabled(targetScreenshareState, screenshareSettings)
|
||||||
|
.catch(logger.error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startTracks,
|
||||||
|
requestJoinAndPublish,
|
||||||
|
requestDisconnect,
|
||||||
|
localMemberState$,
|
||||||
|
participant$,
|
||||||
|
reconnecting$,
|
||||||
|
connected$: matrixAndLivekitConnected$,
|
||||||
|
disconnected$: scope.behavior(
|
||||||
|
homeserverConnected.rtsSession$.pipe(
|
||||||
|
map((state) => state === RTCSessionStatus.Disconnected),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sharingScreen$,
|
||||||
|
toggleScreenSharing,
|
||||||
|
connection$: localConnection$,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function observeSharingScreen$(p: Participant): Observable<boolean> {
|
||||||
|
return observeParticipantEvents(
|
||||||
|
p,
|
||||||
|
ParticipantEvent.TrackPublished,
|
||||||
|
ParticipantEvent.TrackUnpublished,
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
ParticipantEvent.LocalTrackUnpublished,
|
||||||
|
).pipe(map((p) => p.isScreenShareEnabled));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnterRTCSessionOptions {
|
||||||
|
encryptMedia: boolean;
|
||||||
|
matrixRTCMode: MatrixRTCMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the necessary steps to enter the RTC session on the matrix side:
|
||||||
|
* - Preparing the membership info (FOCUS to use, options)
|
||||||
|
* - Sends the matrix event to join the call, and starts the membership manager:
|
||||||
|
* - Delay events management
|
||||||
|
* - Handles retries (fails only after several attempts)
|
||||||
|
*
|
||||||
|
* @param rtcSession - The MatrixRTCSession to join.
|
||||||
|
* @param transport - The LivekitTransport to use for this session.
|
||||||
|
* @param options - Options for entering the RTC session.
|
||||||
|
* @param options.encryptMedia - Whether to encrypt media.
|
||||||
|
* @param options.matrixRTCMode - The Matrix RTC mode to use.
|
||||||
|
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
|
||||||
|
*/
|
||||||
|
// Exported for unit testing
|
||||||
|
export function enterRTCSession(
|
||||||
|
rtcSession: MatrixRTCSession,
|
||||||
|
transport: LivekitTransport,
|
||||||
|
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
|
||||||
|
): void {
|
||||||
|
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||||
|
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||||
|
|
||||||
|
// This must be called before we start trying to join the call, as we need to
|
||||||
|
// have started tracking by the time calls start getting created.
|
||||||
|
// groupCallOTelMembership?.onJoinCall();
|
||||||
|
|
||||||
|
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||||
|
const useDeviceSessionMemberEvents =
|
||||||
|
features?.feature_use_device_session_member_events;
|
||||||
|
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
|
||||||
|
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
|
||||||
|
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
||||||
|
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
||||||
|
rtcSession.joinRoomSession(
|
||||||
|
multiSFU ? [] : [transport],
|
||||||
|
multiSFU ? transport : undefined,
|
||||||
|
{
|
||||||
|
notificationType,
|
||||||
|
callIntent,
|
||||||
|
manageMediaKeys: encryptMedia,
|
||||||
|
...(useDeviceSessionMemberEvents !== undefined && {
|
||||||
|
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||||
|
}),
|
||||||
|
delayedLeaveEventRestartMs:
|
||||||
|
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
||||||
|
delayedLeaveEventDelayMs:
|
||||||
|
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
|
||||||
|
delayedLeaveEventRestartLocalTimeoutMs:
|
||||||
|
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
|
||||||
|
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
||||||
|
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
||||||
|
membershipEventExpiryMs:
|
||||||
|
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||||
|
useExperimentalToDeviceTransport: true,
|
||||||
|
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 Element Creations Ltd.
|
|
||||||
Copyright 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 {
|
|
||||||
type LivekitTransport,
|
|
||||||
type MatrixRTCSession,
|
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
|
||||||
import { map } from "rxjs";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import { MatrixRTCMode } from "../../../settings/settings";
|
|
||||||
import {
|
|
||||||
mockConfig,
|
|
||||||
mockMuteStates,
|
|
||||||
withTestScheduler,
|
|
||||||
} from "../../../utils/test";
|
|
||||||
import {
|
|
||||||
createLocalMembership$,
|
|
||||||
enterRTCSession,
|
|
||||||
LivekitState,
|
|
||||||
} from "./LocalMembership";
|
|
||||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
|
|
||||||
import { Epoch } from "../../ObservableScope";
|
|
||||||
import { constant } from "../../Behavior";
|
|
||||||
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
|
||||||
import { type Publisher } from "./Publisher";
|
|
||||||
|
|
||||||
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
|
|
||||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
|
||||||
vi.mock("../../../UrlParams", () => ({ getUrlParams }));
|
|
||||||
|
|
||||||
describe("LocalMembership", () => {
|
|
||||||
describe("enterRTCSession", () => {
|
|
||||||
it("It joins the correct Session", async () => {
|
|
||||||
const focusFromOlderMembership = {
|
|
||||||
type: "livekit",
|
|
||||||
livekit_service_url: "http://my-oldest-member-service-url.com",
|
|
||||||
livekit_alias: "my-oldest-member-service-alias",
|
|
||||||
};
|
|
||||||
|
|
||||||
const focusConfigFromWellKnown = {
|
|
||||||
type: "livekit",
|
|
||||||
livekit_service_url: "http://my-well-known-service-url.com",
|
|
||||||
};
|
|
||||||
const focusConfigFromWellKnown2 = {
|
|
||||||
type: "livekit",
|
|
||||||
livekit_service_url: "http://my-well-known-service-url2.com",
|
|
||||||
};
|
|
||||||
const clientWellKnown = {
|
|
||||||
"org.matrix.msc4143.rtc_foci": [
|
|
||||||
focusConfigFromWellKnown,
|
|
||||||
focusConfigFromWellKnown2,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
mockConfig({
|
|
||||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation(
|
|
||||||
async (domain) => {
|
|
||||||
if (domain === "example.org") {
|
|
||||||
return Promise.resolve(clientWellKnown);
|
|
||||||
}
|
|
||||||
return Promise.resolve({});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockedSession = vi.mocked({
|
|
||||||
room: {
|
|
||||||
roomId: "roomId",
|
|
||||||
client: {
|
|
||||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
|
||||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
|
||||||
access_token: "ACCCESS_TOKEN",
|
|
||||||
token_type: "Bearer",
|
|
||||||
matrix_server_name: "localhost",
|
|
||||||
expires_in: 10000,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
memberships: [],
|
|
||||||
getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership),
|
|
||||||
getOldestMembership: vi.fn().mockReturnValue({
|
|
||||||
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
|
|
||||||
}),
|
|
||||||
joinRoomSession: vi.fn(),
|
|
||||||
}) as unknown as MatrixRTCSession;
|
|
||||||
|
|
||||||
await enterRTCSession(
|
|
||||||
mockedSession,
|
|
||||||
{
|
|
||||||
livekit_alias: "roomId",
|
|
||||||
livekit_service_url: "http://my-well-known-service-url.com",
|
|
||||||
type: "livekit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
encryptMedia: true,
|
|
||||||
matrixRTCMode: MATRIX_RTC_MODE,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockedSession.joinRoomSession).toHaveBeenLastCalledWith(
|
|
||||||
[
|
|
||||||
{
|
|
||||||
livekit_alias: "roomId",
|
|
||||||
livekit_service_url: "http://my-well-known-service-url.com",
|
|
||||||
type: "livekit",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
undefined,
|
|
||||||
expect.objectContaining({
|
|
||||||
manageMediaKeys: true,
|
|
||||||
useLegacyMemberEvents: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", async () => {
|
|
||||||
mockConfig({});
|
|
||||||
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
|
|
||||||
"org.matrix.msc4143.rtc_foci": [
|
|
||||||
{
|
|
||||||
type: "livekit",
|
|
||||||
livekit_service_url: "http://my-well-known-service-url.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockedSession = vi.mocked({
|
|
||||||
room: {
|
|
||||||
roomId: "roomId",
|
|
||||||
client: {
|
|
||||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
|
||||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
|
||||||
access_token: "ACCCESS_TOKEN",
|
|
||||||
token_type: "Bearer",
|
|
||||||
matrix_server_name: "localhost",
|
|
||||||
expires_in: 10000,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
memberships: [],
|
|
||||||
getFocusInUse: vi.fn(),
|
|
||||||
joinRoomSession: vi.fn(),
|
|
||||||
}) as unknown as MatrixRTCSession;
|
|
||||||
|
|
||||||
await enterRTCSession(
|
|
||||||
mockedSession,
|
|
||||||
{
|
|
||||||
livekit_alias: "roomId",
|
|
||||||
livekit_service_url: "http://my-well-known-service-url.com",
|
|
||||||
type: "livekit",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
encryptMedia: true,
|
|
||||||
matrixRTCMode: MATRIX_RTC_MODE,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultCreateLocalMemberValues = {
|
|
||||||
options: constant({
|
|
||||||
encryptMedia: false,
|
|
||||||
matrixRTCMode: MatrixRTCMode.Matrix_2_0,
|
|
||||||
}),
|
|
||||||
matrixRTCSession: {
|
|
||||||
updateCallIntent: () => {},
|
|
||||||
leaveRoomSession: () => {},
|
|
||||||
} as unknown as MatrixRTCSession,
|
|
||||||
muteStates: mockMuteStates(),
|
|
||||||
isHomeserverConnected: constant(true),
|
|
||||||
trackProcessorState$: constant({
|
|
||||||
supported: false,
|
|
||||||
processor: undefined,
|
|
||||||
}),
|
|
||||||
logger: logger,
|
|
||||||
createPublisherFactory: (): Publisher => ({}) as unknown as Publisher,
|
|
||||||
joinMatrixRTC: async (): Promise<void> => {},
|
|
||||||
homeserverConnected$: constant(true),
|
|
||||||
};
|
|
||||||
|
|
||||||
it("throws error on missing RTC config error", () => {
|
|
||||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
|
||||||
const goodTransport = {
|
|
||||||
livekit_service_url: "other",
|
|
||||||
} as LivekitTransport;
|
|
||||||
|
|
||||||
const localTransport$ = scope.behavior<LivekitTransport>(
|
|
||||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
|
||||||
goodTransport,
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockConnectionManager = {
|
|
||||||
transports$: scope.behavior(
|
|
||||||
localTransport$.pipe(map((t) => new Epoch([t]))),
|
|
||||||
),
|
|
||||||
connectionManagerData$: constant(
|
|
||||||
new Epoch(new ConnectionManagerData()),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const localMembership = createLocalMembership$({
|
|
||||||
scope,
|
|
||||||
...defaultCreateLocalMemberValues,
|
|
||||||
connectionManager: mockConnectionManager,
|
|
||||||
localTransport$,
|
|
||||||
});
|
|
||||||
|
|
||||||
expectObservable(localMembership.connectionState.livekit$).toBe("ne", {
|
|
||||||
n: { state: LivekitState.Uninitialized },
|
|
||||||
e: {
|
|
||||||
state: LivekitState.Error,
|
|
||||||
error: expect.toSatisfy(
|
|
||||||
(e) => e instanceof MatrixRTCTransportMissingError,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,629 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2025 Element Creations Ltd.
|
|
||||||
|
|
||||||
SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type LocalTrack,
|
|
||||||
type Participant,
|
|
||||||
ParticipantEvent,
|
|
||||||
type LocalParticipant,
|
|
||||||
type ScreenShareCaptureOptions,
|
|
||||||
} from "livekit-client";
|
|
||||||
import { observeParticipantEvents } from "@livekit/components-core";
|
|
||||||
import {
|
|
||||||
type LivekitTransport,
|
|
||||||
type MatrixRTCSession,
|
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import {
|
|
||||||
BehaviorSubject,
|
|
||||||
catchError,
|
|
||||||
combineLatest,
|
|
||||||
distinctUntilChanged,
|
|
||||||
map,
|
|
||||||
type Observable,
|
|
||||||
of,
|
|
||||||
scan,
|
|
||||||
switchMap,
|
|
||||||
tap,
|
|
||||||
} from "rxjs";
|
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import { type Behavior } from "../../Behavior";
|
|
||||||
import { type IConnectionManager } from "../remoteMembers/ConnectionManager";
|
|
||||||
import { ObservableScope } from "../../ObservableScope";
|
|
||||||
import { type Publisher } from "./Publisher";
|
|
||||||
import { type MuteStates } from "../../MuteStates";
|
|
||||||
import { and$ } from "../../../utils/observable";
|
|
||||||
import { ElementCallError, UnknownCallError } from "../../../utils/errors";
|
|
||||||
import { ElementWidgetActions, widget } from "../../../widget";
|
|
||||||
import { getUrlParams } from "../../../UrlParams.ts";
|
|
||||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
|
||||||
import { MatrixRTCMode } from "../../../settings/settings.ts";
|
|
||||||
import { Config } from "../../../config/Config.ts";
|
|
||||||
import {
|
|
||||||
type Connection,
|
|
||||||
type ConnectionState,
|
|
||||||
} from "../remoteMembers/Connection.ts";
|
|
||||||
|
|
||||||
export enum LivekitState {
|
|
||||||
Uninitialized = "uninitialized",
|
|
||||||
Connecting = "connecting",
|
|
||||||
Connected = "connected",
|
|
||||||
Error = "error",
|
|
||||||
Disconnected = "disconnected",
|
|
||||||
Disconnecting = "disconnecting",
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalMemberLivekitState =
|
|
||||||
| { state: LivekitState.Error; error: ElementCallError }
|
|
||||||
| { state: LivekitState.Connected }
|
|
||||||
| { state: LivekitState.Connecting }
|
|
||||||
| { state: LivekitState.Uninitialized }
|
|
||||||
| { state: LivekitState.Disconnected }
|
|
||||||
| { state: LivekitState.Disconnecting };
|
|
||||||
|
|
||||||
export enum MatrixState {
|
|
||||||
Connected = "connected",
|
|
||||||
Disconnected = "disconnected",
|
|
||||||
Connecting = "connecting",
|
|
||||||
Error = "Error",
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalMemberMatrixState =
|
|
||||||
| { state: MatrixState.Connected }
|
|
||||||
| { state: MatrixState.Connecting }
|
|
||||||
| { state: MatrixState.Disconnected }
|
|
||||||
| { state: MatrixState.Error; error: Error };
|
|
||||||
|
|
||||||
export interface LocalMemberConnectionState {
|
|
||||||
livekit$: Behavior<LocalMemberLivekitState>;
|
|
||||||
matrix$: Behavior<LocalMemberMatrixState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* - get well known
|
|
||||||
* - get oldest membership
|
|
||||||
* - get transport to use
|
|
||||||
* - get openId + jwt token
|
|
||||||
* - wait for createTrack() call
|
|
||||||
* - create tracks
|
|
||||||
* - wait for join() call
|
|
||||||
* - Publisher.publishTracks()
|
|
||||||
* - send join state/sticky event
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
// TODO add a comment into some code style readme or file header callviewmodel
|
|
||||||
// that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes
|
|
||||||
scope: ObservableScope;
|
|
||||||
muteStates: MuteStates;
|
|
||||||
connectionManager: IConnectionManager;
|
|
||||||
createPublisherFactory: (connection: Connection) => Publisher;
|
|
||||||
joinMatrixRTC: (trasnport: LivekitTransport) => Promise<void>;
|
|
||||||
homeserverConnected$: Behavior<boolean>;
|
|
||||||
localTransport$: Behavior<LivekitTransport | null>;
|
|
||||||
matrixRTCSession: Pick<
|
|
||||||
MatrixRTCSession,
|
|
||||||
"updateCallIntent" | "leaveRoomSession"
|
|
||||||
>;
|
|
||||||
logger: Logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is responsible for managing the own membership in a room.
|
|
||||||
* We want
|
|
||||||
* - a publisher
|
|
||||||
* -
|
|
||||||
* @param param0
|
|
||||||
* @returns
|
|
||||||
* - publisher: The handle to create tracks and publish them to the room.
|
|
||||||
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
|
|
||||||
* - transport$: the transport object the ownMembership$ ended up using.
|
|
||||||
* - connectionState: the current connection state. Including matrix server and livekit server connection.
|
|
||||||
* - sharingScreen$: Whether we are sharing our screen. `undefined` if we cannot share the screen.
|
|
||||||
*/
|
|
||||||
export const createLocalMembership$ = ({
|
|
||||||
scope,
|
|
||||||
connectionManager,
|
|
||||||
localTransport$: localTransportCanThrow$,
|
|
||||||
homeserverConnected$,
|
|
||||||
createPublisherFactory,
|
|
||||||
joinMatrixRTC,
|
|
||||||
logger: parentLogger,
|
|
||||||
muteStates,
|
|
||||||
matrixRTCSession,
|
|
||||||
}: Props): {
|
|
||||||
requestConnect: () => LocalMemberConnectionState;
|
|
||||||
startTracks: () => Behavior<LocalTrack[]>;
|
|
||||||
requestDisconnect: () => Observable<LocalMemberLivekitState> | null;
|
|
||||||
connectionState: LocalMemberConnectionState;
|
|
||||||
sharingScreen$: Behavior<boolean>;
|
|
||||||
/**
|
|
||||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
|
||||||
*/
|
|
||||||
toggleScreenSharing: (() => void) | null;
|
|
||||||
participant$: Behavior<LocalParticipant | null>;
|
|
||||||
connection$: Behavior<Connection | null>;
|
|
||||||
homeserverConnected$: Behavior<boolean>;
|
|
||||||
// deprecated fields
|
|
||||||
/** @deprecated use state instead*/
|
|
||||||
connected$: Behavior<boolean>;
|
|
||||||
// this needs to be discussed
|
|
||||||
/** @deprecated use state instead*/
|
|
||||||
reconnecting$: Behavior<boolean>;
|
|
||||||
} => {
|
|
||||||
const logger = parentLogger.getChild("[LocalMembership]");
|
|
||||||
logger.debug(`Creating local membership..`);
|
|
||||||
const state = {
|
|
||||||
livekit$: new BehaviorSubject<LocalMemberLivekitState>({
|
|
||||||
state: LivekitState.Uninitialized,
|
|
||||||
}),
|
|
||||||
matrix$: new BehaviorSubject<LocalMemberMatrixState>({
|
|
||||||
state: MatrixState.Disconnected,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// This should be used in a combineLatest with publisher$ to connect.
|
|
||||||
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
|
||||||
const trackStartRequested$ = new BehaviorSubject(false);
|
|
||||||
|
|
||||||
// This should be used in a combineLatest with publisher$ to connect.
|
|
||||||
// to make it possible to call startTracks before the preferredTransport$ has resolved.
|
|
||||||
const connectRequested$ = new BehaviorSubject(false);
|
|
||||||
|
|
||||||
// This should be used in a combineLatest with publisher$ to connect.
|
|
||||||
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
|
|
||||||
|
|
||||||
// unwrap the local transport and set the state of the LocalMembership to error in case the transport is an error.
|
|
||||||
const localTransport$ = scope.behavior(
|
|
||||||
localTransportCanThrow$.pipe(
|
|
||||||
catchError((e: unknown) => {
|
|
||||||
let error: ElementCallError;
|
|
||||||
if (e instanceof ElementCallError) {
|
|
||||||
error = e;
|
|
||||||
} else {
|
|
||||||
error = new UnknownCallError(
|
|
||||||
e instanceof Error
|
|
||||||
? e
|
|
||||||
: new Error("Unknown error from localTransport"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
state.livekit$.next({ state: LivekitState.Error, error });
|
|
||||||
return of(null);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Drop Epoch data here since we will not combine this anymore
|
|
||||||
const localConnection$ = scope.behavior(
|
|
||||||
combineLatest([
|
|
||||||
connectionManager.connectionManagerData$,
|
|
||||||
localTransport$,
|
|
||||||
]).pipe(
|
|
||||||
map(([connectionData, localTransport]) => {
|
|
||||||
if (localTransport === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return connectionData.value.getConnectionForTransport(localTransport);
|
|
||||||
}),
|
|
||||||
tap((connection) => {
|
|
||||||
logger.info(
|
|
||||||
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Whether we are "fully" connected to the call. Accounts for both the
|
|
||||||
// * connection to the MatrixRTC session and the LiveKit publish connection.
|
|
||||||
// */
|
|
||||||
// // TODO use this in combination with the MemberState.
|
|
||||||
const connected$ = scope.behavior(
|
|
||||||
and$(
|
|
||||||
homeserverConnected$,
|
|
||||||
localConnection$.pipe(
|
|
||||||
switchMap((c) =>
|
|
||||||
c
|
|
||||||
? c.state$.pipe(map((state) => state.state === "ConnectedToLkRoom"))
|
|
||||||
: of(false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const publisher$ = new BehaviorSubject<Publisher | null>(null);
|
|
||||||
localConnection$.pipe(scope.bind()).subscribe((connection) => {
|
|
||||||
if (connection !== null && publisher$.value === null) {
|
|
||||||
// TODO looks strange to not change publisher if connection changes.
|
|
||||||
// @toger5 will take care of this!
|
|
||||||
publisher$.next(createPublisherFactory(connection));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// const mutestate= publisher$.pipe(switchMap((publisher) => {
|
|
||||||
// return publisher.muteState$
|
|
||||||
// });
|
|
||||||
|
|
||||||
combineLatest([publisher$, trackStartRequested$]).subscribe(
|
|
||||||
([publisher, shouldStartTracks]) => {
|
|
||||||
if (publisher && shouldStartTracks) {
|
|
||||||
publisher
|
|
||||||
.createAndSetupTracks()
|
|
||||||
.then((tracks) => {
|
|
||||||
tracks$.next(tracks);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logger.error("Error creating tracks:", error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// MATRIX RELATED
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Whether we should tell the user that we're reconnecting to the call.
|
|
||||||
// */
|
|
||||||
// DISCUSSION is there a better way to do this?
|
|
||||||
// sth that is more deriectly implied from the membership manager of the js sdk. (fromEvent(matrixRTCSession, Reconnecting)) ??? or similar
|
|
||||||
const reconnecting$ = scope.behavior(
|
|
||||||
connected$.pipe(
|
|
||||||
// We are reconnecting if we previously had some successful initial
|
|
||||||
// connection but are now disconnected
|
|
||||||
scan(
|
|
||||||
({ connectedPreviously }, connectedNow) => ({
|
|
||||||
connectedPreviously: connectedPreviously || connectedNow,
|
|
||||||
reconnecting: connectedPreviously && !connectedNow,
|
|
||||||
}),
|
|
||||||
{ connectedPreviously: false, reconnecting: false },
|
|
||||||
),
|
|
||||||
map(({ reconnecting }) => reconnecting),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const startTracks = (): Behavior<LocalTrack[]> => {
|
|
||||||
trackStartRequested$.next(true);
|
|
||||||
return tracks$;
|
|
||||||
};
|
|
||||||
|
|
||||||
combineLatest([publisher$, tracks$]).subscribe(([publisher, tracks]) => {
|
|
||||||
if (
|
|
||||||
tracks.length === 0 ||
|
|
||||||
// change this to !== Publishing
|
|
||||||
state.livekit$.value.state !== LivekitState.Uninitialized
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.livekit$.next({ state: LivekitState.Connecting });
|
|
||||||
publisher
|
|
||||||
?.startPublishing()
|
|
||||||
.then(() => {
|
|
||||||
state.livekit$.next({ state: LivekitState.Connected });
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
state.livekit$.next({ state: LivekitState.Error, error });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
combineLatest([localTransport$, connectRequested$]).subscribe(
|
|
||||||
// TODO reconnect when transport changes => create test.
|
|
||||||
([transport, connectRequested]) => {
|
|
||||||
if (
|
|
||||||
transport === null ||
|
|
||||||
!connectRequested ||
|
|
||||||
state.matrix$.value.state !== MatrixState.Disconnected
|
|
||||||
) {
|
|
||||||
logger.info(
|
|
||||||
"Not yet connecting because: ",
|
|
||||||
"transport === null:",
|
|
||||||
transport === null,
|
|
||||||
"!connectRequested:",
|
|
||||||
!connectRequested,
|
|
||||||
"state.matrix$.value.state !== MatrixState.Disconnected:",
|
|
||||||
state.matrix$.value.state !== MatrixState.Disconnected,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.matrix$.next({ state: MatrixState.Connecting });
|
|
||||||
logger.info("Matrix State connecting");
|
|
||||||
|
|
||||||
joinMatrixRTC(transport).catch((error) => {
|
|
||||||
logger.error(error);
|
|
||||||
state.matrix$.next({ state: MatrixState.Error, error });
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO add this and update `state.matrix$` based on it.
|
|
||||||
// useTypedEventEmitter(
|
|
||||||
// rtcSession,
|
|
||||||
// MatrixRTCSessionEvent.MembershipManagerError,
|
|
||||||
// (error) => setExternalError(new ConnectionLostError()),
|
|
||||||
// );
|
|
||||||
|
|
||||||
const requestConnect = (): LocalMemberConnectionState => {
|
|
||||||
trackStartRequested$.next(true);
|
|
||||||
connectRequested$.next(true);
|
|
||||||
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestDisconnect = (): Behavior<LocalMemberLivekitState> | null => {
|
|
||||||
if (state.livekit$.value.state !== LivekitState.Connected) return null;
|
|
||||||
state.livekit$.next({ state: LivekitState.Disconnecting });
|
|
||||||
combineLatest([publisher$, tracks$], (publisher, tracks) => {
|
|
||||||
publisher
|
|
||||||
?.stopPublishing()
|
|
||||||
.then(() => {
|
|
||||||
tracks.forEach((track) => track.stop());
|
|
||||||
state.livekit$.next({ state: LivekitState.Disconnected });
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
state.livekit$.next({ state: LivekitState.Error, error });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return state.livekit$;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pause upstream of all local media tracks when we're disconnected from
|
|
||||||
// MatrixRTC, because it can be an unpleasant surprise for the app to say
|
|
||||||
// 'reconnecting' and yet still be transmitting your media to others.
|
|
||||||
// We use matrixConnected$ rather than reconnecting$ because we want to
|
|
||||||
// pause tracks during the initial joining sequence too until we're sure
|
|
||||||
// that our own media is displayed on screen.
|
|
||||||
combineLatest([localConnection$, homeserverConnected$])
|
|
||||||
.pipe(scope.bind())
|
|
||||||
.subscribe(([connection, connected]) => {
|
|
||||||
if (connection?.state$.value.state !== "ConnectedToLkRoom") return;
|
|
||||||
const publications =
|
|
||||||
connection.livekitRoom.localParticipant.trackPublications.values();
|
|
||||||
if (connected) {
|
|
||||||
for (const p of publications) {
|
|
||||||
if (p.track?.isUpstreamPaused === true) {
|
|
||||||
const kind = p.track.kind;
|
|
||||||
logger.info(
|
|
||||||
`Resuming ${kind} track (MatrixRTC connection present)`,
|
|
||||||
);
|
|
||||||
p.track
|
|
||||||
.resumeUpstream()
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error(
|
|
||||||
`Failed to resume ${kind} track after MatrixRTC reconnection`,
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const p of publications) {
|
|
||||||
if (p.track?.isUpstreamPaused === false) {
|
|
||||||
const kind = p.track.kind;
|
|
||||||
logger.info(
|
|
||||||
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
|
|
||||||
);
|
|
||||||
p.track
|
|
||||||
.pauseUpstream()
|
|
||||||
.catch((e) =>
|
|
||||||
logger.error(
|
|
||||||
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// TODO: Refactor updateCallIntent to sth like this:
|
|
||||||
// combineLatest([muteStates.video.enabled$,localTransport$, state.matrix$]).pipe(map(()=>{
|
|
||||||
// matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
|
|
||||||
// }))
|
|
||||||
//
|
|
||||||
|
|
||||||
// TODO I do not fully understand what this does.
|
|
||||||
// Is it needed?
|
|
||||||
// Is this at the right place?
|
|
||||||
// Can this be simplified?
|
|
||||||
// Start and stop session membership as needed
|
|
||||||
// Discussed in statndup -> It seems we can remove this (there is another call to enterRTCSession in this file)
|
|
||||||
// MAKE SURE TO UNDERSTAND why reconcile is needed and what is potentially missing from the alternative enterRTCSession block.
|
|
||||||
// @toger5 will try to take care of this.
|
|
||||||
scope.reconcile(localTransport$, async (transport) => {
|
|
||||||
if (transport !== null && transport !== undefined) {
|
|
||||||
try {
|
|
||||||
state.matrix$.next({ state: MatrixState.Connecting });
|
|
||||||
await joinMatrixRTC(transport);
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Error entering RTC session", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update our member event when our mute state changes.
|
|
||||||
const intentScope = new ObservableScope();
|
|
||||||
intentScope.reconcile(muteStates.video.enabled$, async (videoEnabled) =>
|
|
||||||
matrixRTCSession.updateCallIntent(videoEnabled ? "video" : "audio"),
|
|
||||||
);
|
|
||||||
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
intentScope.end();
|
|
||||||
// Only sends Matrix leave event. The LiveKit session will disconnect
|
|
||||||
// as soon as either the stopConnection$ handler above gets to it or
|
|
||||||
// the view model is destroyed.
|
|
||||||
try {
|
|
||||||
await matrixRTCSession.leaveRoomSession();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Error leaving RTC session", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await widget?.api.transport.send(ElementWidgetActions.HangupCall, {});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to send hangup action", e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
localConnection$
|
|
||||||
.pipe(
|
|
||||||
distinctUntilChanged(),
|
|
||||||
switchMap((c) =>
|
|
||||||
c === null ? of({ state: "Initialized" } as ConnectionState) : c.state$,
|
|
||||||
),
|
|
||||||
map((s) => {
|
|
||||||
logger.trace(`Local connection state update: ${s.state}`);
|
|
||||||
if (s.state == "FailedToStart") {
|
|
||||||
return s.error instanceof ElementCallError
|
|
||||||
? s.error
|
|
||||||
: new UnknownCallError(s.error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
scope.bind(),
|
|
||||||
)
|
|
||||||
.subscribe((error) => {
|
|
||||||
if (error !== undefined)
|
|
||||||
state.livekit$.next({ state: LivekitState.Error, error });
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the user is currently sharing their screen.
|
|
||||||
*/
|
|
||||||
const sharingScreen$ = scope.behavior(
|
|
||||||
localConnection$.pipe(
|
|
||||||
switchMap((c) =>
|
|
||||||
c !== null
|
|
||||||
? observeSharingScreen$(c.livekitRoom.localParticipant)
|
|
||||||
: of(false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
let toggleScreenSharing = null;
|
|
||||||
if (
|
|
||||||
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
|
|
||||||
!getUrlParams().hideScreensharing
|
|
||||||
) {
|
|
||||||
toggleScreenSharing = (): void => {
|
|
||||||
const screenshareSettings: ScreenShareCaptureOptions = {
|
|
||||||
audio: true,
|
|
||||||
selfBrowserSurface: "include",
|
|
||||||
surfaceSwitching: "include",
|
|
||||||
systemAudio: "include",
|
|
||||||
};
|
|
||||||
const targetScreenshareState = !sharingScreen$.value;
|
|
||||||
logger.info(
|
|
||||||
`toggleScreenSharing called. Switching ${
|
|
||||||
targetScreenshareState ? "On" : "Off"
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
// If a connection is ready, toggle screen sharing.
|
|
||||||
// We deliberately do nothing in the case of a null connection because
|
|
||||||
// it looks nice for the call control buttons to all become available
|
|
||||||
// at once upon joining the call, rather than introducing a disabled
|
|
||||||
// state. The user can just click again.
|
|
||||||
// We also allow screen sharing to be toggled even if the connection
|
|
||||||
// is still initializing or publishing tracks, because there's no
|
|
||||||
// technical reason to disallow this. LiveKit will publish if it can.
|
|
||||||
localConnection$.value?.livekitRoom.localParticipant
|
|
||||||
.setScreenShareEnabled(targetScreenshareState, screenshareSettings)
|
|
||||||
.catch(logger.error);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const participant$ = scope.behavior(
|
|
||||||
localConnection$.pipe(map((c) => c?.livekitRoom?.localParticipant ?? null)),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
startTracks,
|
|
||||||
requestConnect,
|
|
||||||
requestDisconnect,
|
|
||||||
connectionState: state,
|
|
||||||
homeserverConnected$,
|
|
||||||
connected$,
|
|
||||||
reconnecting$,
|
|
||||||
|
|
||||||
sharingScreen$,
|
|
||||||
toggleScreenSharing,
|
|
||||||
participant$,
|
|
||||||
connection$: localConnection$,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function observeSharingScreen$(p: Participant): Observable<boolean> {
|
|
||||||
return observeParticipantEvents(
|
|
||||||
p,
|
|
||||||
ParticipantEvent.TrackPublished,
|
|
||||||
ParticipantEvent.TrackUnpublished,
|
|
||||||
ParticipantEvent.LocalTrackPublished,
|
|
||||||
ParticipantEvent.LocalTrackUnpublished,
|
|
||||||
).pipe(map((p) => p.isScreenShareEnabled));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EnterRTCSessionOptions {
|
|
||||||
encryptMedia: boolean;
|
|
||||||
matrixRTCMode: MatrixRTCMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Does the necessary steps to enter the RTC session on the matrix side:
|
|
||||||
* - Preparing the membership info (FOCUS to use, options)
|
|
||||||
* - Sends the matrix event to join the call, and starts the membership manager:
|
|
||||||
* - Delay events management
|
|
||||||
* - Handles retries (fails only after several attempts)
|
|
||||||
*
|
|
||||||
* @param rtcSession
|
|
||||||
* @param transport
|
|
||||||
* @param options
|
|
||||||
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
|
|
||||||
*/
|
|
||||||
// Exported for unit testing
|
|
||||||
export async function enterRTCSession(
|
|
||||||
rtcSession: MatrixRTCSession,
|
|
||||||
transport: LivekitTransport,
|
|
||||||
{ encryptMedia, matrixRTCMode }: EnterRTCSessionOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
|
||||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
|
||||||
|
|
||||||
// This must be called before we start trying to join the call, as we need to
|
|
||||||
// have started tracking by the time calls start getting created.
|
|
||||||
// groupCallOTelMembership?.onJoinCall();
|
|
||||||
|
|
||||||
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
|
||||||
const useDeviceSessionMemberEvents =
|
|
||||||
features?.feature_use_device_session_member_events;
|
|
||||||
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
|
|
||||||
const multiSFU = matrixRTCMode !== MatrixRTCMode.Legacy;
|
|
||||||
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
|
|
||||||
// TODO where/how do we track errors originating from the ongoing rtcSession?
|
|
||||||
rtcSession.joinRoomSession(
|
|
||||||
multiSFU ? [] : [transport],
|
|
||||||
multiSFU ? transport : undefined,
|
|
||||||
{
|
|
||||||
notificationType,
|
|
||||||
callIntent,
|
|
||||||
manageMediaKeys: encryptMedia,
|
|
||||||
...(useDeviceSessionMemberEvents !== undefined && {
|
|
||||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
|
||||||
}),
|
|
||||||
delayedLeaveEventRestartMs:
|
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
|
||||||
delayedLeaveEventDelayMs:
|
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
|
|
||||||
delayedLeaveEventRestartLocalTimeoutMs:
|
|
||||||
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
|
|
||||||
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
|
||||||
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
|
||||||
membershipEventExpiryMs:
|
|
||||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
|
||||||
useExperimentalToDeviceTransport: true,
|
|
||||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (widget) {
|
|
||||||
await widget.api.transport.send(ElementWidgetActions.JoinCall, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import {
|
||||||
|
afterEach,
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
type MockedObject,
|
||||||
|
vi,
|
||||||
|
} from "vitest";
|
||||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import { mockConfig, flushPromises } from "../../../utils/test";
|
import { mockConfig, flushPromises } from "../../../utils/test";
|
||||||
import { createLocalTransport$ } from "./LocalTransport";
|
import { createLocalTransport$ } from "./LocalTransport";
|
||||||
@@ -17,31 +27,22 @@ import {
|
|||||||
FailToGetOpenIdToken,
|
FailToGetOpenIdToken,
|
||||||
} from "../../../utils/errors";
|
} from "../../../utils/errors";
|
||||||
import * as openIDSFU from "../../../livekit/openIDSFU";
|
import * as openIDSFU from "../../../livekit/openIDSFU";
|
||||||
|
import { customLivekitUrl } from "../../../settings/settings";
|
||||||
|
import { testJWTToken } from "../../../utils/test-fixtures";
|
||||||
|
|
||||||
describe("LocalTransport", () => {
|
describe("LocalTransport", () => {
|
||||||
|
const openIdResponse: openIDSFU.SFUConfig = {
|
||||||
|
url: "https://lk.example.org",
|
||||||
|
jwt: testJWTToken,
|
||||||
|
livekitAlias: "!example_room_id",
|
||||||
|
livekitIdentity: "@lk_user:ABCDEF",
|
||||||
|
};
|
||||||
|
|
||||||
let scope: ObservableScope;
|
let scope: ObservableScope;
|
||||||
beforeEach(() => (scope = new ObservableScope()));
|
beforeEach(() => {
|
||||||
afterEach(() => scope.end());
|
scope = new ObservableScope();
|
||||||
|
|
||||||
it("throws if config is missing", async () => {
|
|
||||||
const localTransport$ = createLocalTransport$({
|
|
||||||
scope,
|
|
||||||
roomId: "!room:example.org",
|
|
||||||
useOldestMember$: constant(false),
|
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
|
||||||
client: {
|
|
||||||
getDomain: () => "",
|
|
||||||
// These won't be called in this error path but satisfy the type
|
|
||||||
getOpenIdToken: vi.fn(),
|
|
||||||
getDeviceId: vi.fn(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await flushPromises();
|
|
||||||
|
|
||||||
expect(() => localTransport$.value).toThrow(
|
|
||||||
new MatrixRTCTransportMissingError(""),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
afterEach(() => scope.end());
|
||||||
|
|
||||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||||
// Provide a valid config so makeTransportInternal resolves a transport
|
// Provide a valid config so makeTransportInternal resolves a transport
|
||||||
@@ -60,12 +61,14 @@ describe("LocalTransport", () => {
|
|||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const localTransport$ = createLocalTransport$({
|
const localTransport$ = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!room:example.org",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember$: constant(false),
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
client: {
|
client: {
|
||||||
// Use empty domain to skip .well-known and use config directly
|
// Use empty domain to skip .well-known and use config directly
|
||||||
getDomain: () => "",
|
getDomain: () => "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
getOpenIdToken: vi.fn(),
|
getOpenIdToken: vi.fn(),
|
||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -83,12 +86,12 @@ describe("LocalTransport", () => {
|
|||||||
expect(() => localTransport$.value).toThrow(expectedError);
|
expect(() => localTransport$.value).toThrow(expectedError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits preferred transport after OpenID resolves", async () => {
|
it("updates local transport when oldest member changes", async () => {
|
||||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
||||||
mockConfig({
|
mockConfig({
|
||||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||||
});
|
});
|
||||||
|
const memberships$ = new BehaviorSubject(new Epoch([]));
|
||||||
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||||
|
|
||||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||||
@@ -97,24 +100,171 @@ describe("LocalTransport", () => {
|
|||||||
|
|
||||||
const localTransport$ = createLocalTransport$({
|
const localTransport$ = createLocalTransport$({
|
||||||
scope,
|
scope,
|
||||||
roomId: "!room:example.org",
|
roomId: "!example_room_id",
|
||||||
useOldestMember$: constant(false),
|
useOldestMember$: constant(true),
|
||||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
memberships$,
|
||||||
client: {
|
client: {
|
||||||
getDomain: () => "",
|
getDomain: () => "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
getOpenIdToken: vi.fn(),
|
getOpenIdToken: vi.fn(),
|
||||||
getDeviceId: vi.fn(),
|
getDeviceId: vi.fn(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
openIdResolver.resolve?.({ url: "https://lk.example.org", jwt: "jwt" });
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
expect(localTransport$.value).toBe(null);
|
expect(localTransport$.value).toBe(null);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// final
|
// final
|
||||||
expect(localTransport$.value).toStrictEqual({
|
expect(localTransport$.value).toStrictEqual({
|
||||||
livekit_alias: "!room:example.org",
|
livekit_alias: "!example_room_id",
|
||||||
livekit_service_url: "https://lk.example.org",
|
livekit_service_url: "https://lk.example.org",
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
|
||||||
|
|
||||||
|
describe("transport configuration mechanisms", () => {
|
||||||
|
let localTransportOpts: LocalTransportProps & {
|
||||||
|
client: MockedObject<LocalTransportProps["client"]>;
|
||||||
|
};
|
||||||
|
let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConfig({});
|
||||||
|
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
|
||||||
|
localTransportOpts = {
|
||||||
|
scope,
|
||||||
|
roomId: "!example_room_id",
|
||||||
|
useOldestMember$: constant(false),
|
||||||
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
|
client: {
|
||||||
|
getDomain: vi.fn().mockReturnValue(""),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||||
|
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||||
|
openIdResolver.promise,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports getting transport via application config", async () => {
|
||||||
|
mockConfig({
|
||||||
|
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||||
|
});
|
||||||
|
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||||
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
|
expect(localTransport$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
expect(localTransport$.value).toStrictEqual({
|
||||||
|
livekit_alias: "!example_room_id",
|
||||||
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
type: "livekit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("supports getting transport via user settings", async () => {
|
||||||
|
customLivekitUrl.setValue("https://lk.example.org");
|
||||||
|
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||||
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
|
expect(localTransport$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
expect(localTransport$.value).toStrictEqual({
|
||||||
|
livekit_alias: "!example_room_id",
|
||||||
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
type: "livekit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("supports getting transport via backend", async () => {
|
||||||
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||||
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
|
]);
|
||||||
|
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||||
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
|
expect(localTransport$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
expect(localTransport$.value).toStrictEqual({
|
||||||
|
livekit_alias: "!example_room_id",
|
||||||
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
type: "livekit",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("fails fast if the openID request fails for backend config", async () => {
|
||||||
|
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
|
||||||
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
|
]);
|
||||||
|
openIdResolver.reject(
|
||||||
|
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
||||||
|
throw Error("Expected test to throw");
|
||||||
|
} catch (ex) {
|
||||||
|
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("supports getting transport via well-known", async () => {
|
||||||
|
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||||
|
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||||
|
"org.matrix.msc4143.rtc_foci": [
|
||||||
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||||
|
openIdResolver.resolve?.(openIdResponse);
|
||||||
|
expect(localTransport$.value).toBe(null);
|
||||||
|
await flushPromises();
|
||||||
|
expect(localTransport$.value).toStrictEqual({
|
||||||
|
livekit_alias: "!example_room_id",
|
||||||
|
livekit_service_url: "https://lk.example.org",
|
||||||
|
type: "livekit",
|
||||||
|
});
|
||||||
|
expect(fetchMock.done()).toEqual(true);
|
||||||
|
});
|
||||||
|
it("fails fast if the openId request fails for the well-known config", async () => {
|
||||||
|
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||||
|
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||||
|
"org.matrix.msc4143.rtc_foci": [
|
||||||
|
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
openIdResolver.reject(
|
||||||
|
new FailToGetOpenIdToken(new Error("Test driven error")),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await lastValueFrom(createLocalTransport$(localTransportOpts));
|
||||||
|
throw Error("Expected test to throw");
|
||||||
|
} catch (ex) {
|
||||||
|
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("throws if no options are available", async () => {
|
||||||
|
const localTransport$ = createLocalTransport$({
|
||||||
|
scope,
|
||||||
|
roomId: "!example_room_id",
|
||||||
|
useOldestMember$: constant(false),
|
||||||
|
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||||
|
client: {
|
||||||
|
getDomain: () => "",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||||
|
// These won't be called in this error path but satisfy the type
|
||||||
|
getOpenIdToken: vi.fn(),
|
||||||
|
getDeviceId: vi.fn(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(() => localTransport$.value).toThrow(
|
||||||
|
new MatrixRTCTransportMissingError(""),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import {
|
import {
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
isLivekitTransport,
|
isLivekitTransport,
|
||||||
type LivekitTransportConfig,
|
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
isLivekitTransportConfig,
|
isLivekitTransportConfig,
|
||||||
|
type Transport,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
||||||
import {
|
import {
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
@@ -27,7 +27,10 @@ import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
|||||||
import { type Behavior } from "../../Behavior.ts";
|
import { type Behavior } from "../../Behavior.ts";
|
||||||
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
|
||||||
import { Config } from "../../../config/Config.ts";
|
import { Config } from "../../../config/Config.ts";
|
||||||
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
|
import {
|
||||||
|
FailToGetOpenIdToken,
|
||||||
|
MatrixRTCTransportMissingError,
|
||||||
|
} from "../../../utils/errors.ts";
|
||||||
import {
|
import {
|
||||||
getSFUConfigWithOpenID,
|
getSFUConfigWithOpenID,
|
||||||
type OpenIDClientParts,
|
type OpenIDClientParts,
|
||||||
@@ -45,7 +48,8 @@ const logger = rootLogger.getChild("[LocalTransport]");
|
|||||||
interface Props {
|
interface Props {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||||
client: Pick<MatrixClient, "getDomain"> & OpenIDClientParts;
|
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
|
||||||
|
OpenIDClientParts;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
useOldestMember$: Behavior<boolean>;
|
useOldestMember$: Behavior<boolean>;
|
||||||
}
|
}
|
||||||
@@ -85,7 +89,7 @@ export const createLocalTransport$ = ({
|
|||||||
* The transport that we would personally prefer to publish on (if not for the
|
* The transport that we would personally prefer to publish on (if not for the
|
||||||
* transport preferences of others, perhaps).
|
* transport preferences of others, perhaps).
|
||||||
*
|
*
|
||||||
* @throws
|
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||||
*/
|
*/
|
||||||
const preferredTransport$: Behavior<LivekitTransport | null> = scope.behavior(
|
const preferredTransport$: Behavior<LivekitTransport | null> = scope.behavior(
|
||||||
customLivekitUrl.value$.pipe(
|
customLivekitUrl.value$.pipe(
|
||||||
@@ -116,73 +120,150 @@ export const createLocalTransport$ = ({
|
|||||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Determine the correct Transport for the current session, including
|
||||||
|
* validating auth against the service to ensure it's correct.
|
||||||
|
* Prefers in order:
|
||||||
*
|
*
|
||||||
* @param client
|
* 1. The `urlFromDevSettings` value. If this cannot be validated, the function will throw.
|
||||||
* @param roomId
|
* 2. The transports returned via the homeserver.
|
||||||
* @returns
|
* 3. The transports returned via .well-known.
|
||||||
|
* 4. The transport configured in Element Call's config.
|
||||||
|
*
|
||||||
|
* @param client The authenticated Matrix client for the current user
|
||||||
|
* @param roomId The ID of the room to be connected to.
|
||||||
|
* @param urlFromDevSettings Override URL provided by the user's local config.
|
||||||
|
* @returns A fully validated transport config.
|
||||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||||
*/
|
*/
|
||||||
async function makeTransport(
|
async function makeTransport(
|
||||||
client: Pick<MatrixClient, "getDomain"> & OpenIDClientParts,
|
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
|
||||||
|
OpenIDClientParts,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
urlFromDevSettings: string | null,
|
urlFromDevSettings: string | null,
|
||||||
): Promise<LivekitTransport> {
|
): Promise<LivekitTransport> {
|
||||||
let transport: LivekitTransport | undefined;
|
|
||||||
logger.trace("Searching for a preferred transport");
|
logger.trace("Searching for a preferred transport");
|
||||||
//TODO refactor this to use the jwt service returned alias.
|
|
||||||
const livekitAlias = roomId;
|
// We will call `getSFUConfigWithOpenID` once per transport here as it's our
|
||||||
|
// only mechanism of valiation. This means we will also ask the
|
||||||
|
// homeserver for a OpenID token a few times. Since OpenID tokens are single
|
||||||
|
// use we don't want to risk any issues by re-using a token.
|
||||||
|
//
|
||||||
|
// If the OpenID request were to fail then it's acceptable for us to fail
|
||||||
|
// this function early, as we assume the homeserver has got some problems.
|
||||||
|
|
||||||
// DEVTOOL: Highest priority: Load from devtool setting
|
// DEVTOOL: Highest priority: Load from devtool setting
|
||||||
if (urlFromDevSettings !== null) {
|
if (urlFromDevSettings !== null) {
|
||||||
const transportFromStorage: LivekitTransport = {
|
logger.info("Using LiveKit transport from dev tools: ", urlFromDevSettings);
|
||||||
|
// Validate that the SFU is up. Otherwise, we want to fail on this
|
||||||
|
// as we don't permit other SFUs.
|
||||||
|
const config = await getSFUConfigWithOpenID(
|
||||||
|
client,
|
||||||
|
urlFromDevSettings,
|
||||||
|
roomId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
type: "livekit",
|
type: "livekit",
|
||||||
livekit_service_url: urlFromDevSettings,
|
livekit_service_url: urlFromDevSettings,
|
||||||
livekit_alias: livekitAlias,
|
livekit_alias: config.livekitAlias,
|
||||||
};
|
};
|
||||||
logger.info(
|
|
||||||
"Using LiveKit transport from dev tools: ",
|
|
||||||
transportFromStorage,
|
|
||||||
);
|
|
||||||
transport = transportFromStorage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WELL_KNOWN: Prioritize the .well-known/matrix/client, if available, over the configured SFU
|
async function getFirstUsableTransport(
|
||||||
|
transports: Transport[],
|
||||||
|
): Promise<LivekitTransport | null> {
|
||||||
|
for (const potentialTransport of transports) {
|
||||||
|
if (isLivekitTransportConfig(potentialTransport)) {
|
||||||
|
try {
|
||||||
|
const { livekitAlias } = await getSFUConfigWithOpenID(
|
||||||
|
client,
|
||||||
|
potentialTransport.livekit_service_url,
|
||||||
|
roomId,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...potentialTransport,
|
||||||
|
livekit_alias: livekitAlias,
|
||||||
|
};
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex instanceof FailToGetOpenIdToken) {
|
||||||
|
// Explictly throw these
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`Could not use SFU service "${potentialTransport.livekit_service_url}" as SFU`,
|
||||||
|
ex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MSC4143: Attempt to fetch transports from backend.
|
||||||
|
if ("_unstable_getRTCTransports" in client) {
|
||||||
|
try {
|
||||||
|
const selectedTransport = await getFirstUsableTransport(
|
||||||
|
await client._unstable_getRTCTransports(),
|
||||||
|
);
|
||||||
|
if (selectedTransport) {
|
||||||
|
logger.info("Using backend-configured SFU", selectedTransport);
|
||||||
|
return selectedTransport;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex instanceof MatrixError && ex.httpStatus === 404) {
|
||||||
|
// Expected, this is an unstable endpoint and it's not required.
|
||||||
|
logger.debug("Backend does not provide any RTC transports", ex);
|
||||||
|
} else if (ex instanceof FailToGetOpenIdToken) {
|
||||||
|
throw ex;
|
||||||
|
} else {
|
||||||
|
// We got an error that wasn't just missing support for the feature, so log it loudly.
|
||||||
|
logger.error(
|
||||||
|
"Unexpected error fetching RTC transports from backend",
|
||||||
|
ex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy MSC4143 (to be removed) WELL_KNOWN: Prioritize the .well-known/matrix/client, if available.
|
||||||
const domain = client.getDomain();
|
const domain = client.getDomain();
|
||||||
if (domain && transport === undefined) {
|
if (domain) {
|
||||||
// we use AutoDiscovery instead of relying on the MatrixClient having already
|
// we use AutoDiscovery instead of relying on the MatrixClient having already
|
||||||
// been fully configured and started
|
// been fully configured and started
|
||||||
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
|
const wellKnownFoci = (await AutoDiscovery.getRawClientConfig(domain))?.[
|
||||||
FOCI_WK_KEY
|
FOCI_WK_KEY
|
||||||
];
|
];
|
||||||
if (Array.isArray(wellKnownFoci)) {
|
const selectedTransport = Array.isArray(wellKnownFoci)
|
||||||
const wellKnownTransport: LivekitTransportConfig | undefined =
|
? await getFirstUsableTransport(wellKnownFoci)
|
||||||
wellKnownFoci.find((f) => f && isLivekitTransportConfig(f));
|
: null;
|
||||||
if (wellKnownTransport !== undefined) {
|
if (selectedTransport) {
|
||||||
logger.info("Using LiveKit transport from .well-known: ", transport);
|
logger.info("Using .well-known SFU", selectedTransport);
|
||||||
transport = { ...wellKnownTransport, livekit_alias: livekitAlias };
|
return selectedTransport;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CONFIG: Least prioritized; Load from config file
|
// CONFIG: Least prioritized; Load from config file
|
||||||
const urlFromConf = Config.get().livekit?.livekit_service_url;
|
const urlFromConf = Config.get().livekit?.livekit_service_url;
|
||||||
if (urlFromConf && transport === undefined) {
|
if (urlFromConf) {
|
||||||
const transportFromConf: LivekitTransport = {
|
try {
|
||||||
type: "livekit",
|
const { livekitAlias } = await getSFUConfigWithOpenID(
|
||||||
livekit_service_url: urlFromConf,
|
client,
|
||||||
livekit_alias: livekitAlias,
|
urlFromConf,
|
||||||
};
|
roomId,
|
||||||
logger.info("Using LiveKit transport from config: ", transportFromConf);
|
);
|
||||||
transport = transportFromConf;
|
const selectedTransport: LivekitTransport = {
|
||||||
|
type: "livekit",
|
||||||
|
livekit_service_url: urlFromConf,
|
||||||
|
livekit_alias: livekitAlias,
|
||||||
|
};
|
||||||
|
logger.info("Using config SFU", selectedTransport);
|
||||||
|
return selectedTransport;
|
||||||
|
} catch (ex) {
|
||||||
|
if (ex instanceof FailToGetOpenIdToken) {
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
logger.error("Failed to validate config SFU", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!transport) throw new MatrixRTCTransportMissingError(domain ?? ""); // this will call the jwt/sfu/get endpoint to pre create the livekit room.
|
throw new MatrixRTCTransportMissingError(domain ?? "");
|
||||||
|
|
||||||
await getSFUConfigWithOpenID(
|
|
||||||
client,
|
|
||||||
transport.livekit_service_url,
|
|
||||||
transport.livekit_alias,
|
|
||||||
);
|
|
||||||
|
|
||||||
return transport;
|
|
||||||
}
|
}
|
||||||
|
|||||||
360
src/state/CallViewModel/localMember/Publisher.test.ts
Normal file
360
src/state/CallViewModel/localMember/Publisher.test.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations 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, describe, expect, it, test, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
LocalParticipant,
|
||||||
|
type LocalTrack,
|
||||||
|
type LocalTrackPublication,
|
||||||
|
ParticipantEvent,
|
||||||
|
Track,
|
||||||
|
} from "livekit-client";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { ObservableScope } from "../../ObservableScope";
|
||||||
|
import { constant } from "../../Behavior";
|
||||||
|
import {
|
||||||
|
flushPromises,
|
||||||
|
mockLivekitRoom,
|
||||||
|
mockMediaDevices,
|
||||||
|
} from "../../../utils/test";
|
||||||
|
import { Publisher } from "./Publisher";
|
||||||
|
import { type Connection } from "../remoteMembers/Connection";
|
||||||
|
import { type MuteStates } from "../../MuteStates";
|
||||||
|
|
||||||
|
let scope: ObservableScope;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
scope = new ObservableScope();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => scope.end());
|
||||||
|
|
||||||
|
function createMockLocalTrack(source: Track.Source): LocalTrack {
|
||||||
|
const track = {
|
||||||
|
source,
|
||||||
|
isMuted: false,
|
||||||
|
isUpstreamPaused: false,
|
||||||
|
} as Partial<LocalTrack> as LocalTrack;
|
||||||
|
|
||||||
|
vi.mocked(track).mute = vi.fn().mockImplementation(() => {
|
||||||
|
track.isMuted = true;
|
||||||
|
});
|
||||||
|
vi.mocked(track).unmute = vi.fn().mockImplementation(() => {
|
||||||
|
track.isMuted = false;
|
||||||
|
});
|
||||||
|
vi.mocked(track).pauseUpstream = vi.fn().mockImplementation(() => {
|
||||||
|
// @ts-expect-error - for that test we want to set isUpstreamPaused directly
|
||||||
|
track.isUpstreamPaused = true;
|
||||||
|
});
|
||||||
|
vi.mocked(track).resumeUpstream = vi.fn().mockImplementation(() => {
|
||||||
|
// @ts-expect-error - for that test we want to set isUpstreamPaused directly
|
||||||
|
track.isUpstreamPaused = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
|
||||||
|
enabled$: BehaviorSubject<boolean>;
|
||||||
|
setHandler: (h: (enabled: boolean) => void) => void;
|
||||||
|
unsetHandler: () => void;
|
||||||
|
} {
|
||||||
|
let currentHandler = (enabled: boolean): void => {};
|
||||||
|
|
||||||
|
const ms = {
|
||||||
|
enabled$,
|
||||||
|
setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => {
|
||||||
|
currentHandler = h;
|
||||||
|
}),
|
||||||
|
unsetHandler: vi.fn().mockImplementation(() => {
|
||||||
|
currentHandler = (enabled: boolean): void => {};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
// forward enabled$ emissions to the current handler
|
||||||
|
enabled$.subscribe((enabled) => {
|
||||||
|
logger.info(`MockMuteState: enabled changed to ${enabled}`);
|
||||||
|
currentHandler(enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
let connection: Connection;
|
||||||
|
let muteStates: MuteStates;
|
||||||
|
let localParticipant: LocalParticipant;
|
||||||
|
let audioEnabled$: BehaviorSubject<boolean>;
|
||||||
|
let videoEnabled$: BehaviorSubject<boolean>;
|
||||||
|
let trackPublications: LocalTrackPublication[];
|
||||||
|
// use it to control when track creation resolves, default to resolved
|
||||||
|
let createTrackLock: Promise<void>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trackPublications = [];
|
||||||
|
audioEnabled$ = new BehaviorSubject(false);
|
||||||
|
videoEnabled$ = new BehaviorSubject(false);
|
||||||
|
createTrackLock = Promise.resolve();
|
||||||
|
|
||||||
|
muteStates = {
|
||||||
|
audio: createMockMuteState(audioEnabled$),
|
||||||
|
video: createMockMuteState(videoEnabled$),
|
||||||
|
} as unknown as MuteStates;
|
||||||
|
|
||||||
|
const mockSendDataPacket = vi.fn();
|
||||||
|
const mockEngine = {
|
||||||
|
client: {
|
||||||
|
sendUpdateLocalMetadata: vi.fn(),
|
||||||
|
},
|
||||||
|
on: vi.fn().mockReturnThis(),
|
||||||
|
sendDataPacket: mockSendDataPacket,
|
||||||
|
};
|
||||||
|
|
||||||
|
localParticipant = new LocalParticipant(
|
||||||
|
"local-sid",
|
||||||
|
"local-identity",
|
||||||
|
// @ts-expect-error - for that test we want a real LocalParticipant to have the pending publications logic
|
||||||
|
mockEngine,
|
||||||
|
{
|
||||||
|
adaptiveStream: true,
|
||||||
|
dynacase: false,
|
||||||
|
audioCaptureDefaults: {},
|
||||||
|
videoCaptureDefaults: {},
|
||||||
|
stopLocalTrackOnUnpublish: true,
|
||||||
|
reconnectPolicy: "always",
|
||||||
|
disconnectOnPageLeave: true,
|
||||||
|
},
|
||||||
|
new Map(),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(localParticipant).createTracks = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (opts) => {
|
||||||
|
const tracks: LocalTrack[] = [];
|
||||||
|
if (opts.audio) {
|
||||||
|
tracks.push(createMockLocalTrack(Track.Source.Microphone));
|
||||||
|
}
|
||||||
|
if (opts.video) {
|
||||||
|
tracks.push(createMockLocalTrack(Track.Source.Camera));
|
||||||
|
}
|
||||||
|
await createTrackLock;
|
||||||
|
return tracks;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(localParticipant).publishTrack = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (track: LocalTrack) => {
|
||||||
|
const pub = {
|
||||||
|
track,
|
||||||
|
source: track.source,
|
||||||
|
mute: track.mute,
|
||||||
|
unmute: track.unmute,
|
||||||
|
} as Partial<LocalTrackPublication> as LocalTrackPublication;
|
||||||
|
trackPublications.push(pub);
|
||||||
|
localParticipant.emit(ParticipantEvent.LocalTrackPublished, pub);
|
||||||
|
return Promise.resolve(pub);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(localParticipant).getTrackPublication = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((source: Track.Source) => {
|
||||||
|
return trackPublications.find((pub) => pub.track?.source === source);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection = {
|
||||||
|
state$: constant({
|
||||||
|
state: "ConnectedToLkRoom",
|
||||||
|
livekitConnectionState$: constant(LivekitConnectionState.Connected),
|
||||||
|
}),
|
||||||
|
livekitRoom: mockLivekitRoom({
|
||||||
|
localParticipant: localParticipant,
|
||||||
|
}),
|
||||||
|
} as unknown as Connection;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Publisher", () => {
|
||||||
|
let publisher: Publisher;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
publisher = new Publisher(
|
||||||
|
scope,
|
||||||
|
connection,
|
||||||
|
mockMediaDevices({}),
|
||||||
|
muteStates,
|
||||||
|
constant({ supported: false, processor: undefined }),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {});
|
||||||
|
|
||||||
|
it("Should not create tracks if started muted to avoid unneeded permission requests", async () => {
|
||||||
|
const createTracksSpy = vi.spyOn(
|
||||||
|
connection.livekitRoom.localParticipant,
|
||||||
|
"createTracks",
|
||||||
|
);
|
||||||
|
|
||||||
|
audioEnabled$.next(false);
|
||||||
|
videoEnabled$.next(false);
|
||||||
|
await publisher.createAndSetupTracks();
|
||||||
|
|
||||||
|
expect(createTracksSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Should minimize permission request by querying create at once", async () => {
|
||||||
|
const enableCameraAndMicrophoneSpy = vi.spyOn(
|
||||||
|
localParticipant,
|
||||||
|
"enableCameraAndMicrophone",
|
||||||
|
);
|
||||||
|
const createTracksSpy = vi.spyOn(localParticipant, "createTracks");
|
||||||
|
|
||||||
|
audioEnabled$.next(true);
|
||||||
|
videoEnabled$.next(true);
|
||||||
|
await publisher.createAndSetupTracks();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(enableCameraAndMicrophoneSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// It should create both at once
|
||||||
|
expect(createTracksSpy).toHaveBeenCalledWith({
|
||||||
|
audio: true,
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ensure no data is streamed until publish has been called", async () => {
|
||||||
|
audioEnabled$.next(true);
|
||||||
|
await publisher.createAndSetupTracks();
|
||||||
|
|
||||||
|
// The track should be created and paused
|
||||||
|
expect(localParticipant.createTracks).toHaveBeenCalledWith({
|
||||||
|
audio: true,
|
||||||
|
video: undefined,
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
expect(localParticipant.publishTrack).toHaveBeenCalled();
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
const track = localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Microphone,
|
||||||
|
)?.track;
|
||||||
|
expect(track).toBeDefined();
|
||||||
|
expect(track!.pauseUpstream).toHaveBeenCalled();
|
||||||
|
expect(track!.isUpstreamPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ensure resume upstream when published is called", async () => {
|
||||||
|
videoEnabled$.next(true);
|
||||||
|
await publisher.createAndSetupTracks();
|
||||||
|
// await flushPromises();
|
||||||
|
await publisher.startPublishing();
|
||||||
|
|
||||||
|
const track = localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Camera,
|
||||||
|
)?.track;
|
||||||
|
expect(track).toBeDefined();
|
||||||
|
// expect(track.pauseUpstream).toHaveBeenCalled();
|
||||||
|
expect(track!.isUpstreamPaused).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Mute states", () => {
|
||||||
|
let publisher: Publisher;
|
||||||
|
beforeEach(() => {
|
||||||
|
publisher = new Publisher(
|
||||||
|
scope,
|
||||||
|
connection,
|
||||||
|
mockMediaDevices({}),
|
||||||
|
muteStates,
|
||||||
|
constant({ supported: false, processor: undefined }),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{ mutes: { audioEnabled: true, videoEnabled: false } },
|
||||||
|
{ mutes: { audioEnabled: true, videoEnabled: false } },
|
||||||
|
])("only create the tracks that are unmuted $mutes", async ({ mutes }) => {
|
||||||
|
// Ensure all muted
|
||||||
|
audioEnabled$.next(mutes.audioEnabled);
|
||||||
|
videoEnabled$.next(mutes.videoEnabled);
|
||||||
|
|
||||||
|
vi.mocked(connection.livekitRoom.localParticipant).createTracks = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await publisher.createAndSetupTracks();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
connection.livekitRoom.localParticipant.createTracks,
|
||||||
|
).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
connection.livekitRoom.localParticipant.createTracks,
|
||||||
|
).toHaveBeenCalledWith({
|
||||||
|
audio: mutes.audioEnabled ? true : undefined,
|
||||||
|
video: mutes.videoEnabled ? true : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does mute unmute audio", async () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Bug fix", () => {
|
||||||
|
// There is a race condition when creating and publishing tracks while the mute state changes.
|
||||||
|
// This race condition could cause tracks to be published even though they are muted at the
|
||||||
|
// beginning of a call coming from lobby.
|
||||||
|
// This is caused by our stack using manually the low level API to create and publish tracks,
|
||||||
|
// but also using the higher level setMicrophoneEnabled and setCameraEnabled functions that also create
|
||||||
|
// and publish tracks, and managing pending publications.
|
||||||
|
// Race is as follow, on creation of the Publisher we create the tracks then publish them.
|
||||||
|
// If in the middle of that process the mute state changes:
|
||||||
|
// - the `setMicrophoneEnabled` will be no-op because it is not aware of our created track and can't see any pending publication
|
||||||
|
// - If start publication is requested it will publish the track even though there was a mute request.
|
||||||
|
it("wrongly publish tracks while muted", async () => {
|
||||||
|
// setLogLevel(`debug`);
|
||||||
|
const publisher = new Publisher(
|
||||||
|
scope,
|
||||||
|
connection,
|
||||||
|
mockMediaDevices({}),
|
||||||
|
muteStates,
|
||||||
|
constant({ supported: false, processor: undefined }),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
audioEnabled$.next(true);
|
||||||
|
|
||||||
|
const resolvers = Promise.withResolvers<void>();
|
||||||
|
createTrackLock = resolvers.promise;
|
||||||
|
|
||||||
|
// Initially the audio is unmuted, so creating tracks should publish the audio track
|
||||||
|
const createTracks = publisher.createAndSetupTracks();
|
||||||
|
void publisher.startPublishing();
|
||||||
|
void createTracks.then(() => {
|
||||||
|
void publisher.startPublishing();
|
||||||
|
});
|
||||||
|
// now mute the audio before allowing track creation to complete
|
||||||
|
audioEnabled$.next(false);
|
||||||
|
resolvers.resolve(undefined);
|
||||||
|
await createTracks;
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const track = localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Microphone,
|
||||||
|
)?.track;
|
||||||
|
expect(track).toBeDefined();
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(localParticipant.publishTrack).not.toHaveBeenCalled();
|
||||||
|
} catch {
|
||||||
|
expect(track!.mute).toHaveBeenCalled();
|
||||||
|
expect(track!.isMuted).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
ConnectionState as LivekitConnectionState,
|
||||||
|
type LocalTrackPublication,
|
||||||
LocalVideoTrack,
|
LocalVideoTrack,
|
||||||
|
ParticipantEvent,
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
Track,
|
Track,
|
||||||
type LocalTrack,
|
|
||||||
type LocalTrackPublication,
|
|
||||||
ConnectionState as LivekitConnectionState,
|
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import {
|
import {
|
||||||
map,
|
map,
|
||||||
@@ -40,31 +40,36 @@ import { type ObservableScope } from "../../ObservableScope.ts";
|
|||||||
* The Publisher is also responsible for creating the media tracks.
|
* The Publisher is also responsible for creating the media tracks.
|
||||||
*/
|
*/
|
||||||
export class Publisher {
|
export class Publisher {
|
||||||
public tracks: LocalTrack<Track.Kind>[] = [];
|
/**
|
||||||
|
* By default, livekit will start publishing tracks as soon as they are created.
|
||||||
|
* In the matrix RTC world, we want to control when tracks are published based
|
||||||
|
* on whether the user is part of the RTC session or not.
|
||||||
|
*/
|
||||||
|
public shouldPublish = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Publisher.
|
* Creates a new Publisher.
|
||||||
* @param scope - The observable scope to use for managing the publisher.
|
* @param scope - The observable scope to use for managing the publisher.
|
||||||
* @param connection - The connection to use for publishing.
|
* @param connection - The connection to use for publishing.
|
||||||
* @param devices - The media devices to use for audio and video input.
|
* @param devices - The media devices to use for audio and video input.
|
||||||
* @param muteStates - The mute states for audio and video.
|
* @param muteStates - The mute states for audio and video.
|
||||||
* @param e2eeLivekitOptions - The E2EE options to use for the LiveKit room. Use to share the same key provider across connections!.
|
|
||||||
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
|
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
|
||||||
|
* @param logger - The logger to use for logging :D.
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private scope: ObservableScope,
|
private scope: ObservableScope,
|
||||||
private connection: Connection,
|
private connection: Pick<Connection, "livekitRoom" | "state$">, //setE2EEEnabled,
|
||||||
devices: MediaDevices,
|
devices: MediaDevices,
|
||||||
private readonly muteStates: MuteStates,
|
private readonly muteStates: MuteStates,
|
||||||
trackerProcessorState$: Behavior<ProcessorState>,
|
trackerProcessorState$: Behavior<ProcessorState>,
|
||||||
private logger?: Logger,
|
private logger: Logger,
|
||||||
) {
|
) {
|
||||||
this.logger?.info("[PublishConnection] Create LiveKit room");
|
|
||||||
const { controlledAudioDevices } = getUrlParams();
|
const { controlledAudioDevices } = getUrlParams();
|
||||||
|
|
||||||
const room = connection.livekitRoom;
|
const room = connection.livekitRoom;
|
||||||
|
|
||||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||||
this.logger?.error("Failed to set E2EE enabled on room", e);
|
this.logger.error("Failed to set E2EE enabled on room", e);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup track processor syncing (blur)
|
// Setup track processor syncing (blur)
|
||||||
@@ -74,117 +79,187 @@ export class Publisher {
|
|||||||
|
|
||||||
this.workaroundRestartAudioInputTrackChrome(devices, scope);
|
this.workaroundRestartAudioInputTrackChrome(devices, scope);
|
||||||
this.scope.onEnd(() => {
|
this.scope.onEnd(() => {
|
||||||
this.logger?.info(
|
this.logger.info("Scope ended -> stop publishing all tracks");
|
||||||
"[PublishConnection] Scope ended -> stop publishing all tracks",
|
|
||||||
);
|
|
||||||
void this.stopPublishing();
|
void this.stopPublishing();
|
||||||
|
muteStates.audio.unsetHandler();
|
||||||
|
muteStates.video.unsetHandler();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.connection.livekitRoom.localParticipant.on(
|
||||||
|
ParticipantEvent.LocalTrackPublished,
|
||||||
|
this.onLocalTrackPublished.bind(this),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LiveKit will publish the tracks as soon as they are created
|
||||||
|
// but we want to control when tracks are published.
|
||||||
|
// We cannot just mute the tracks, even if this will effectively stop the publishing,
|
||||||
|
// it would also prevent the user from seeing their own video/audio preview.
|
||||||
|
// So for that we use pauseUpStream(): Stops sending media to the server by replacing
|
||||||
|
// the sender track with null, but keeps the local MediaStreamTrack active.
|
||||||
|
// The user can still see/hear themselves locally, but remote participants see nothing.
|
||||||
|
private onLocalTrackPublished(
|
||||||
|
localTrackPublication: LocalTrackPublication,
|
||||||
|
): void {
|
||||||
|
this.logger.info("Local track published", localTrackPublication);
|
||||||
|
const lkRoom = this.connection.livekitRoom;
|
||||||
|
if (!this.shouldPublish) {
|
||||||
|
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
|
||||||
|
this.logger.error(`Failed to pause upstreams`, e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// also check the mute state and apply it
|
||||||
|
if (localTrackPublication.source === Track.Source.Microphone) {
|
||||||
|
const enabled = this.muteStates.audio.enabled$.value;
|
||||||
|
lkRoom.localParticipant.setMicrophoneEnabled(enabled).catch((e) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to enable microphone track, enabled:${enabled}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else if (localTrackPublication.source === Track.Source.Camera) {
|
||||||
|
const enabled = this.muteStates.video.enabled$.value;
|
||||||
|
lkRoom.localParticipant.setCameraEnabled(enabled).catch((e) => {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to enable camera track, enabled:${enabled}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create and setup local audio and video tracks based on the current mute states.
|
||||||
|
* It creates the tracks only if audio and/or video is enabled, to avoid unnecessary
|
||||||
|
* permission prompts.
|
||||||
|
*
|
||||||
|
* It also observes mute state changes to update LiveKit microphone/camera states accordingly.
|
||||||
|
* If a track is not created initially because disabled, it will be created when unmuting.
|
||||||
|
*
|
||||||
|
* This call is not blocking anymore, instead callers can listen to the
|
||||||
|
* `RoomEvent.MediaDevicesError` event in the LiveKit room to be notified of any errors.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public async createAndSetupTracks(): Promise<void> {
|
||||||
|
this.logger.debug("createAndSetupTracks called");
|
||||||
|
const lkRoom = this.connection.livekitRoom;
|
||||||
|
// Observe mute state changes and update LiveKit microphone/camera states accordingly
|
||||||
|
this.observeMuteStates();
|
||||||
|
|
||||||
|
// Check if audio and/or video is enabled. We only create tracks if enabled,
|
||||||
|
// because it could prompt for permission, and we don't want to do that unnecessarily.
|
||||||
|
const audio = this.muteStates.audio.enabled$.value;
|
||||||
|
const video = this.muteStates.video.enabled$.value;
|
||||||
|
|
||||||
|
// We don't await the creation, because livekit could block until the tracks
|
||||||
|
// are fully published, and not only that they are created.
|
||||||
|
// We don't have control on that, localParticipant creates and publishes the tracks
|
||||||
|
// asap.
|
||||||
|
// We are using the `ParticipantEvent.LocalTrackPublished` to be notified
|
||||||
|
// when tracks are actually published, and at that point
|
||||||
|
// we can pause upstream if needed (depending on if startPublishing has been called).
|
||||||
|
if (audio && video) {
|
||||||
|
// Enable both at once in order to have a single permission prompt!
|
||||||
|
void lkRoom.localParticipant.enableCameraAndMicrophone();
|
||||||
|
} else if (audio) {
|
||||||
|
void lkRoom.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
} else if (video) {
|
||||||
|
void lkRoom.localParticipant.setCameraEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pauseUpstreams(
|
||||||
|
lkRoom: LivekitRoom,
|
||||||
|
sources: Track.Source[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const source of sources) {
|
||||||
|
const track = lkRoom.localParticipant.getTrackPublication(source)?.track;
|
||||||
|
if (track) {
|
||||||
|
await track.pauseUpstream();
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`No track found for source ${source} to pause upstream`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resumeUpstreams(
|
||||||
|
lkRoom: LivekitRoom,
|
||||||
|
sources: Track.Source[],
|
||||||
|
): Promise<void> {
|
||||||
|
for (const source of sources) {
|
||||||
|
const track = lkRoom.localParticipant.getTrackPublication(source)?.track;
|
||||||
|
if (track) {
|
||||||
|
await track.resumeUpstream();
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`No track found for source ${source} to resume upstream`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the connection to LiveKit and publish local tracks.
|
|
||||||
*
|
*
|
||||||
* This will:
|
* Request to publish local tracks to the LiveKit room.
|
||||||
* wait for the connection to be ready.
|
* This will wait for the connection to be ready before publishing.
|
||||||
// * 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.)
|
* Livekit also have some local retry logic for publishing tracks.
|
||||||
// * 2. Use this token to request the SFU config to the MatrixRtc authentication service.
|
* Can be called multiple times, localparticipant manages the state of published tracks (or pending publications).
|
||||||
// * 3. Connect to the configured LiveKit room.
|
|
||||||
// * 4. Create local audio and video tracks based on the current mute states and publish them to the room.
|
|
||||||
*
|
*
|
||||||
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
|
* @returns
|
||||||
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
|
|
||||||
*/
|
*/
|
||||||
public async createAndSetupTracks(): Promise<LocalTrack[]> {
|
public async startPublishing(): Promise<void> {
|
||||||
const lkRoom = this.connection.livekitRoom;
|
if (this.shouldPublish) {
|
||||||
// Observe mute state changes and update LiveKit microphone/camera states accordingly
|
this.logger.debug(`Already publishing, ignoring startPublishing call`);
|
||||||
this.observeMuteStates(this.scope);
|
return;
|
||||||
|
|
||||||
// TODO: This should be an autostarted connection no need to start here. just check the connection state.
|
|
||||||
// TODO: This will fetch the JWT token. Perhaps we could keep it preloaded
|
|
||||||
// instead? This optimization would only be safe for a publish connection,
|
|
||||||
// because we don't want to leak the user's intent to perhaps join a call to
|
|
||||||
// remote servers before they actually commit to it.
|
|
||||||
// const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
||||||
// const sub = this.connection.state$.subscribe((s) => {
|
|
||||||
// if (s.state === "FailedToStart") {
|
|
||||||
// reject(new Error("Disconnected from LiveKit server"));
|
|
||||||
// } else if (s.state === "ConnectedToLkRoom") {
|
|
||||||
// resolve();
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// try {
|
|
||||||
// await promise;
|
|
||||||
// } catch (e) {
|
|
||||||
// throw e;
|
|
||||||
// } finally {
|
|
||||||
// sub.unsubscribe();
|
|
||||||
// }
|
|
||||||
// TODO-MULTI-SFU: Prepublish a microphone track
|
|
||||||
const audio = this.muteStates.audio.enabled$.value;
|
|
||||||
const video = this.muteStates.video.enabled$.value;
|
|
||||||
// createTracks throws if called with audio=false and video=false
|
|
||||||
if (audio || video) {
|
|
||||||
// TODO this can still throw errors? It will also prompt for permissions if not already granted
|
|
||||||
this.tracks =
|
|
||||||
(await lkRoom.localParticipant
|
|
||||||
.createTracks({
|
|
||||||
audio,
|
|
||||||
video,
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.logger?.error("Failed to create tracks", error);
|
|
||||||
})) ?? [];
|
|
||||||
}
|
}
|
||||||
return this.tracks;
|
this.shouldPublish = true;
|
||||||
}
|
this.logger.debug("startPublishing called");
|
||||||
|
|
||||||
public async startPublishing(): Promise<LocalTrack[]> {
|
|
||||||
const lkRoom = this.connection.livekitRoom;
|
const lkRoom = this.connection.livekitRoom;
|
||||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
||||||
const sub = this.connection.state$.subscribe((s) => {
|
// Resume upstream for both audio and video tracks
|
||||||
switch (s.state) {
|
// We need to call it explicitly because call setTrackEnabled does not always
|
||||||
case "ConnectedToLkRoom":
|
// resume upstream. It will only if you switch the track from disabled to enabled,
|
||||||
resolve();
|
// but if the track is already enabled but upstream is paused, it won't resume it.
|
||||||
break;
|
// TODO what about screen share?
|
||||||
case "FailedToStart":
|
|
||||||
reject(new Error("Failed to connect to LiveKit server"));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
this.logger?.info("waiting for connection: ", s.state);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
await promise;
|
await this.resumeUpstreams(lkRoom, [
|
||||||
|
Track.Source.Microphone,
|
||||||
|
Track.Source.Camera,
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw e;
|
this.logger.error(`Failed to resume upstreams`, e);
|
||||||
} finally {
|
|
||||||
sub.unsubscribe();
|
|
||||||
}
|
}
|
||||||
for (const track of this.tracks) {
|
|
||||||
// TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally
|
|
||||||
// with a timeout.
|
|
||||||
await lkRoom.localParticipant.publishTrack(track).catch((error) => {
|
|
||||||
this.logger?.error("Failed to publish track", error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: check if the connection is still active? and break the loop if not?
|
|
||||||
}
|
|
||||||
return this.tracks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async stopPublishing(): Promise<void> {
|
public async stopPublishing(): Promise<void> {
|
||||||
// TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope
|
this.logger.debug("stopPublishing called");
|
||||||
// actually has the right lifetime
|
this.shouldPublish = false;
|
||||||
this.muteStates.audio.unsetHandler();
|
// Pause upstream will stop sending media to the server, while keeping
|
||||||
this.muteStates.video.unsetHandler();
|
// the local MediaStreamTrack active, so the user can still see themselves.
|
||||||
|
await this.pauseUpstreams(this.connection.livekitRoom, [
|
||||||
|
Track.Source.Microphone,
|
||||||
|
Track.Source.Camera,
|
||||||
|
Track.Source.ScreenShare,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
const localParticipant = this.connection.livekitRoom.localParticipant;
|
public async stopTracks(): Promise<void> {
|
||||||
const tracks: LocalTrack[] = [];
|
const lkRoom = this.connection.livekitRoom;
|
||||||
const addToTracksIfDefined = (p: LocalTrackPublication): void => {
|
for (const source of [
|
||||||
if (p.track !== undefined) tracks.push(p.track);
|
Track.Source.Microphone,
|
||||||
};
|
Track.Source.Camera,
|
||||||
localParticipant.trackPublications.forEach(addToTracksIfDefined);
|
Track.Source.ScreenShare,
|
||||||
await localParticipant.unpublishTracks(tracks);
|
]) {
|
||||||
|
const localPub = lkRoom.localParticipant.getTrackPublication(source);
|
||||||
|
if (localPub?.track) {
|
||||||
|
// stops and unpublishes the track
|
||||||
|
await lkRoom.localParticipant.unpublishTrack(localPub!.track, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Private methods
|
/// Private methods
|
||||||
@@ -221,6 +296,9 @@ export class Publisher {
|
|||||||
// the process of being restarted.
|
// the process of being restarted.
|
||||||
activeMicTrack.mediaStreamTrack.readyState !== "ended"
|
activeMicTrack.mediaStreamTrack.readyState !== "ended"
|
||||||
) {
|
) {
|
||||||
|
this.logger?.info(
|
||||||
|
"Restarting audio device track due to active media device changed (workaroundRestartAudioInputTrackChrome)",
|
||||||
|
);
|
||||||
// Restart the track, which will cause Livekit to do another
|
// Restart the track, which will cause Livekit to do another
|
||||||
// getUserMedia() call with deviceId: default to get the *new* default device.
|
// getUserMedia() call with deviceId: default to get the *new* default device.
|
||||||
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
||||||
@@ -229,7 +307,7 @@ export class Publisher {
|
|||||||
.getTrackPublication(Track.Source.Microphone)
|
.getTrackPublication(Track.Source.Microphone)
|
||||||
?.audioTrack?.restartTrack()
|
?.audioTrack?.restartTrack()
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
this.logger?.error(`Failed to restart audio device track`, e);
|
this.logger.error(`Failed to restart audio device track`, e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -249,7 +327,7 @@ export class Publisher {
|
|||||||
selected$.pipe(scope.bind()).subscribe((device) => {
|
selected$.pipe(scope.bind()).subscribe((device) => {
|
||||||
if (lkRoom.state != LivekitConnectionState.Connected) return;
|
if (lkRoom.state != LivekitConnectionState.Connected) return;
|
||||||
// if (this.connectionState$.value !== ConnectionState.Connected) return;
|
// if (this.connectionState$.value !== ConnectionState.Connected) return;
|
||||||
this.logger?.info(
|
this.logger.info(
|
||||||
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
|
||||||
lkRoom.getActiveDevice(kind),
|
lkRoom.getActiveDevice(kind),
|
||||||
" !== ",
|
" !== ",
|
||||||
@@ -262,7 +340,7 @@ export class Publisher {
|
|||||||
lkRoom
|
lkRoom
|
||||||
.switchActiveDevice(kind, device.id)
|
.switchActiveDevice(kind, device.id)
|
||||||
.catch((e: Error) =>
|
.catch((e: Error) =>
|
||||||
this.logger?.error(
|
this.logger.error(
|
||||||
`Failed to sync ${kind} device with LiveKit`,
|
`Failed to sync ${kind} device with LiveKit`,
|
||||||
e,
|
e,
|
||||||
),
|
),
|
||||||
@@ -278,30 +356,37 @@ export class Publisher {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Observe changes in the mute states and update the LiveKit room accordingly.
|
* Observe changes in the mute states and update the LiveKit room accordingly.
|
||||||
* @param scope
|
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private observeMuteStates(scope: ObservableScope): void {
|
private observeMuteStates(): void {
|
||||||
const lkRoom = this.connection.livekitRoom;
|
const lkRoom = this.connection.livekitRoom;
|
||||||
this.muteStates.audio.setHandler(async (desired) => {
|
this.muteStates.audio.setHandler(async (enable) => {
|
||||||
try {
|
try {
|
||||||
await lkRoom.localParticipant.setMicrophoneEnabled(desired);
|
this.logger.debug(
|
||||||
} catch (e) {
|
`handler: Setting LiveKit microphone enabled: ${enable}`,
|
||||||
this.logger?.error(
|
|
||||||
"Failed to update LiveKit audio input mute state",
|
|
||||||
e,
|
|
||||||
);
|
);
|
||||||
|
await lkRoom.localParticipant.setMicrophoneEnabled(enable);
|
||||||
|
// Unmute will restart the track if it was paused upstream,
|
||||||
|
// but until explicitly requested, we want to keep it paused.
|
||||||
|
if (!this.shouldPublish && enable) {
|
||||||
|
await this.pauseUpstreams(lkRoom, [Track.Source.Microphone]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error("Failed to update LiveKit audio input mute state", e);
|
||||||
}
|
}
|
||||||
return lkRoom.localParticipant.isMicrophoneEnabled;
|
return lkRoom.localParticipant.isMicrophoneEnabled;
|
||||||
});
|
});
|
||||||
this.muteStates.video.setHandler(async (desired) => {
|
this.muteStates.video.setHandler(async (enable) => {
|
||||||
try {
|
try {
|
||||||
await lkRoom.localParticipant.setCameraEnabled(desired);
|
this.logger.debug(`handler: Setting LiveKit camera enabled: ${enable}`);
|
||||||
|
await lkRoom.localParticipant.setCameraEnabled(enable);
|
||||||
|
// Unmute will restart the track if it was paused upstream,
|
||||||
|
// but until explicitly requested, we want to keep it paused.
|
||||||
|
if (!this.shouldPublish && enable) {
|
||||||
|
await this.pauseUpstreams(lkRoom, [Track.Source.Camera]);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger?.error(
|
this.logger.error("Failed to update LiveKit video input mute state", e);
|
||||||
"Failed to update LiveKit video input mute state",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return lkRoom.localParticipant.isCameraEnabled;
|
return lkRoom.localParticipant.isCameraEnabled;
|
||||||
});
|
});
|
||||||
@@ -315,7 +400,7 @@ export class Publisher {
|
|||||||
const track$ = scope.behavior(
|
const track$ = scope.behavior(
|
||||||
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
observeTrackReference$(room.localParticipant, Track.Source.Camera).pipe(
|
||||||
map((trackRef) => {
|
map((trackRef) => {
|
||||||
const track = trackRef?.publication?.track;
|
const track = trackRef?.publication.track;
|
||||||
return track instanceof LocalVideoTrack ? track : null;
|
return track instanceof LocalVideoTrack ? track : null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -30,13 +30,17 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import type { LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import type { LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import {
|
import {
|
||||||
Connection,
|
Connection,
|
||||||
|
ConnectionState,
|
||||||
type ConnectionOpts,
|
type ConnectionOpts,
|
||||||
type ConnectionState,
|
|
||||||
type PublishingParticipant,
|
|
||||||
} from "./Connection.ts";
|
} from "./Connection.ts";
|
||||||
import { ObservableScope } from "../../ObservableScope.ts";
|
import { ObservableScope } from "../../ObservableScope.ts";
|
||||||
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
|
import { type OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
|
||||||
import { FailToGetOpenIdToken } from "../../../utils/errors.ts";
|
import {
|
||||||
|
ElementCallError,
|
||||||
|
FailToGetOpenIdToken,
|
||||||
|
} from "../../../utils/errors.ts";
|
||||||
|
import { testJWTToken } from "../../../utils/test-fixtures.ts";
|
||||||
|
import { mockRemoteParticipant } from "../../../utils/test.ts";
|
||||||
|
|
||||||
let testScope: ObservableScope;
|
let testScope: ObservableScope;
|
||||||
|
|
||||||
@@ -47,11 +51,6 @@ let fakeLivekitRoom: MockedObject<LivekitRoom>;
|
|||||||
let localParticipantEventEmiter: EventEmitter;
|
let localParticipantEventEmiter: EventEmitter;
|
||||||
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||||
|
|
||||||
let fakeRoomEventEmiter: EventEmitter;
|
|
||||||
// let fakeMembershipsFocusMap$: BehaviorSubject<
|
|
||||||
// { membership: CallMembership; transport: LivekitTransport }[]
|
|
||||||
// >;
|
|
||||||
|
|
||||||
const livekitFocus: LivekitTransport = {
|
const livekitFocus: LivekitTransport = {
|
||||||
livekit_alias: "!roomID:example.org",
|
livekit_alias: "!roomID:example.org",
|
||||||
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
||||||
@@ -88,22 +87,25 @@ function setupTest(): void {
|
|||||||
localParticipantEventEmiter,
|
localParticipantEventEmiter,
|
||||||
),
|
),
|
||||||
} as unknown as LocalParticipant);
|
} as unknown as LocalParticipant);
|
||||||
fakeRoomEventEmiter = new EventEmitter();
|
|
||||||
|
|
||||||
|
const fakeRoomEventEmitter = new EventEmitter();
|
||||||
fakeLivekitRoom = vi.mocked<LivekitRoom>({
|
fakeLivekitRoom = vi.mocked<LivekitRoom>({
|
||||||
connect: vi.fn(),
|
connect: vi.fn(),
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
remoteParticipants: new Map(),
|
remoteParticipants: new Map(),
|
||||||
localParticipant: fakeLocalParticipant,
|
localParticipant: fakeLocalParticipant,
|
||||||
state: LivekitConnectionState.Disconnected,
|
state: LivekitConnectionState.Disconnected,
|
||||||
on: fakeRoomEventEmiter.on.bind(fakeRoomEventEmiter),
|
on: fakeRoomEventEmitter.on.bind(fakeRoomEventEmitter),
|
||||||
off: fakeRoomEventEmiter.off.bind(fakeRoomEventEmiter),
|
off: fakeRoomEventEmitter.off.bind(fakeRoomEventEmitter),
|
||||||
addListener: fakeRoomEventEmiter.addListener.bind(fakeRoomEventEmiter),
|
addListener: fakeRoomEventEmitter.addListener.bind(fakeRoomEventEmitter),
|
||||||
removeListener:
|
removeListener:
|
||||||
fakeRoomEventEmiter.removeListener.bind(fakeRoomEventEmiter),
|
fakeRoomEventEmitter.removeListener.bind(fakeRoomEventEmitter),
|
||||||
removeAllListeners:
|
removeAllListeners:
|
||||||
fakeRoomEventEmiter.removeAllListeners.bind(fakeRoomEventEmiter),
|
fakeRoomEventEmitter.removeAllListeners.bind(fakeRoomEventEmitter),
|
||||||
setE2EEEnabled: vi.fn().mockResolvedValue(undefined),
|
setE2EEEnabled: vi.fn().mockResolvedValue(undefined),
|
||||||
|
emit: (eventName: string | symbol, ...args: unknown[]) => {
|
||||||
|
fakeRoomEventEmitter.emit(eventName, ...args);
|
||||||
|
},
|
||||||
} as unknown as LivekitRoom);
|
} as unknown as LivekitRoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,12 +122,21 @@ function setupRemoteConnection(): Connection {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
|
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
|
||||||
jwt: "ATOKEN",
|
jwt: testJWTToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
fakeLivekitRoom.connect.mockResolvedValue(undefined);
|
fakeLivekitRoom.connect.mockImplementation(async (): Promise<void> => {
|
||||||
|
const changeEv = RoomEvent.ConnectionStateChanged;
|
||||||
|
|
||||||
|
fakeLivekitRoom.state = LivekitConnectionState.Connecting;
|
||||||
|
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||||
|
fakeLivekitRoom.state = LivekitConnectionState.Connected;
|
||||||
|
fakeLivekitRoom.emit(changeEv, fakeLivekitRoom.state);
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
return new Connection(opts, logger);
|
return new Connection(opts, logger);
|
||||||
}
|
}
|
||||||
@@ -148,7 +159,7 @@ describe("Start connection states", () => {
|
|||||||
};
|
};
|
||||||
const connection = new Connection(opts, logger);
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
expect(connection.state$.getValue().state).toEqual("Initialized");
|
expect(connection.state$.getValue()).toEqual("Initialized");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fail to getOpenId token then error state", async () => {
|
it("fail to getOpenId token then error state", async () => {
|
||||||
@@ -164,7 +175,7 @@ describe("Start connection states", () => {
|
|||||||
|
|
||||||
const connection = new Connection(opts, logger);
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
const capturedStates: ConnectionState[] = [];
|
const capturedStates: (ConnectionState | Error)[] = [];
|
||||||
const s = connection.state$.subscribe((value) => {
|
const s = connection.state$.subscribe((value) => {
|
||||||
capturedStates.push(value);
|
capturedStates.push(value);
|
||||||
});
|
});
|
||||||
@@ -184,22 +195,20 @@ describe("Start connection states", () => {
|
|||||||
|
|
||||||
let capturedState = capturedStates.pop();
|
let capturedState = capturedStates.pop();
|
||||||
expect(capturedState).toBeDefined();
|
expect(capturedState).toBeDefined();
|
||||||
expect(capturedState!.state).toEqual("FetchingConfig");
|
expect(capturedState!).toEqual("FetchingConfig");
|
||||||
|
|
||||||
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token")));
|
deferred.reject(new FailToGetOpenIdToken(new Error("Failed to get token")));
|
||||||
|
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
capturedState = capturedStates.pop();
|
capturedState = capturedStates.pop();
|
||||||
if (capturedState!.state === "FailedToStart") {
|
if (capturedState instanceof Error) {
|
||||||
expect(capturedState!.error.message).toEqual("Something went wrong");
|
expect(capturedState.message).toEqual("Something went wrong");
|
||||||
expect(capturedState!.transport.livekit_alias).toEqual(
|
expect(connection.transport.livekit_alias).toEqual(
|
||||||
livekitFocus.livekit_alias,
|
livekitFocus.livekit_alias,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
expect.fail(
|
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||||
"Expected FailedToStart state but got " + capturedState?.state,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,7 +225,7 @@ describe("Start connection states", () => {
|
|||||||
|
|
||||||
const connection = new Connection(opts, logger);
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
const capturedStates: ConnectionState[] = [];
|
const capturedStates: (ConnectionState | Error)[] = [];
|
||||||
const s = connection.state$.subscribe((value) => {
|
const s = connection.state$.subscribe((value) => {
|
||||||
capturedStates.push(value);
|
capturedStates.push(value);
|
||||||
});
|
});
|
||||||
@@ -238,24 +247,25 @@ describe("Start connection states", () => {
|
|||||||
|
|
||||||
let capturedState = capturedStates.pop();
|
let capturedState = capturedStates.pop();
|
||||||
expect(capturedState).toBeDefined();
|
expect(capturedState).toBeDefined();
|
||||||
expect(capturedState?.state).toEqual("FetchingConfig");
|
expect(capturedState).toEqual(ConnectionState.FetchingConfig);
|
||||||
|
|
||||||
deferredSFU.resolve();
|
deferredSFU.resolve();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
capturedState = capturedStates.pop();
|
capturedState = capturedStates.pop();
|
||||||
|
|
||||||
if (capturedState?.state === "FailedToStart") {
|
if (
|
||||||
expect(capturedState?.error.message).toContain(
|
capturedState instanceof ElementCallError &&
|
||||||
"SFU Config fetch failed with exception Error",
|
capturedState.cause instanceof Error
|
||||||
|
) {
|
||||||
|
expect(capturedState.cause.message).toContain(
|
||||||
|
"SFU Config fetch failed with exception",
|
||||||
);
|
);
|
||||||
expect(capturedState?.transport.livekit_alias).toEqual(
|
expect(connection.transport.livekit_alias).toEqual(
|
||||||
livekitFocus.livekit_alias,
|
livekitFocus.livekit_alias,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
expect.fail(
|
expect.fail("Expected FailedToStart state but got " + capturedState);
|
||||||
"Expected FailedToStart state but got " + capturedState?.state,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,7 +282,7 @@ describe("Start connection states", () => {
|
|||||||
|
|
||||||
const connection = new Connection(opts, logger);
|
const connection = new Connection(opts, logger);
|
||||||
|
|
||||||
const capturedStates: ConnectionState[] = [];
|
const capturedStates: (ConnectionState | Error)[] = [];
|
||||||
const s = connection.state$.subscribe((value) => {
|
const s = connection.state$.subscribe((value) => {
|
||||||
capturedStates.push(value);
|
capturedStates.push(value);
|
||||||
});
|
});
|
||||||
@@ -285,7 +295,7 @@ describe("Start connection states", () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
|
url: "wss://matrix-rtc.m.localhost/livekit/sfu",
|
||||||
jwt: "ATOKEN",
|
jwt: testJWTToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -302,18 +312,21 @@ describe("Start connection states", () => {
|
|||||||
let capturedState = capturedStates.pop();
|
let capturedState = capturedStates.pop();
|
||||||
expect(capturedState).toBeDefined();
|
expect(capturedState).toBeDefined();
|
||||||
|
|
||||||
expect(capturedState?.state).toEqual("FetchingConfig");
|
expect(capturedState).toEqual(ConnectionState.FetchingConfig);
|
||||||
|
|
||||||
deferredSFU.resolve();
|
deferredSFU.resolve();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
capturedState = capturedStates.pop();
|
capturedState = capturedStates.pop();
|
||||||
|
|
||||||
if (capturedState && capturedState?.state === "FailedToStart") {
|
if (
|
||||||
expect(capturedState.error.message).toContain(
|
capturedState instanceof ElementCallError &&
|
||||||
|
capturedState.cause instanceof Error
|
||||||
|
) {
|
||||||
|
expect(capturedState.cause.message).toContain(
|
||||||
"Failed to connect to livekit",
|
"Failed to connect to livekit",
|
||||||
);
|
);
|
||||||
expect(capturedState.transport.livekit_alias).toEqual(
|
expect(connection.transport.livekit_alias).toEqual(
|
||||||
livekitFocus.livekit_alias,
|
livekitFocus.livekit_alias,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -329,7 +342,7 @@ describe("Start connection states", () => {
|
|||||||
|
|
||||||
const connection = setupRemoteConnection();
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
const capturedStates: ConnectionState[] = [];
|
const capturedStates: (ConnectionState | Error)[] = [];
|
||||||
const s = connection.state$.subscribe((value) => {
|
const s = connection.state$.subscribe((value) => {
|
||||||
capturedStates.push(value);
|
capturedStates.push(value);
|
||||||
});
|
});
|
||||||
@@ -339,13 +352,15 @@ describe("Start connection states", () => {
|
|||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
const initialState = capturedStates.shift();
|
const initialState = capturedStates.shift();
|
||||||
expect(initialState?.state).toEqual("Initialized");
|
expect(initialState).toEqual(ConnectionState.Initialized);
|
||||||
const fetchingState = capturedStates.shift();
|
const fetchingState = capturedStates.shift();
|
||||||
expect(fetchingState?.state).toEqual("FetchingConfig");
|
expect(fetchingState).toEqual(ConnectionState.FetchingConfig);
|
||||||
|
const disconnectedState = capturedStates.shift();
|
||||||
|
expect(disconnectedState).toEqual(ConnectionState.LivekitDisconnected);
|
||||||
const connectingState = capturedStates.shift();
|
const connectingState = capturedStates.shift();
|
||||||
expect(connectingState?.state).toEqual("ConnectingToLkRoom");
|
expect(connectingState).toEqual(ConnectionState.LivekitConnecting);
|
||||||
const connectedState = capturedStates.shift();
|
const connectedState = capturedStates.shift();
|
||||||
expect(connectedState?.state).toEqual("ConnectedToLkRoom");
|
expect(connectedState).toEqual(ConnectionState.LivekitConnected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shutting down the scope should stop the connection", async () => {
|
it("shutting down the scope should stop the connection", async () => {
|
||||||
@@ -363,46 +378,32 @@ describe("Start connection states", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function fakeRemoteLivekitParticipant(
|
describe("remote participants", () => {
|
||||||
id: string,
|
it("emits the list of remote participants", () => {
|
||||||
publications: number = 1,
|
|
||||||
): RemoteParticipant {
|
|
||||||
return {
|
|
||||||
identity: id,
|
|
||||||
getTrackPublications: () => Array(publications),
|
|
||||||
} as unknown as RemoteParticipant;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Publishing participants observations", () => {
|
|
||||||
it("should emit the list of publishing participants", () => {
|
|
||||||
setupTest();
|
setupTest();
|
||||||
|
|
||||||
const connection = setupRemoteConnection();
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
const bobIsAPublisher = Promise.withResolvers<void>();
|
const observedParticipants: RemoteParticipant[][] = [];
|
||||||
const danIsAPublisher = Promise.withResolvers<void>();
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
const observedPublishers: PublishingParticipant[][] = [];
|
observedParticipants.push(participants);
|
||||||
const s = connection.remoteParticipantsWithTracks$.subscribe(
|
});
|
||||||
(publishers) => {
|
|
||||||
observedPublishers.push(publishers);
|
|
||||||
if (publishers.some((p) => p.identity === "@bob:example.org:DEV111")) {
|
|
||||||
bobIsAPublisher.resolve();
|
|
||||||
}
|
|
||||||
if (publishers.some((p) => p.identity === "@dan:example.org:DEV333")) {
|
|
||||||
danIsAPublisher.resolve();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
onTestFinished(() => s.unsubscribe());
|
onTestFinished(() => s.unsubscribe());
|
||||||
// The publishingParticipants$ observable is derived from the current members of the
|
// The remoteParticipants$ observable is derived from the current members of the
|
||||||
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
// livekitRoom and the rtc membership in order to publish the members that are publishing
|
||||||
// on this connection.
|
// on this connection.
|
||||||
|
|
||||||
let participants: RemoteParticipant[] = [
|
let participants: RemoteParticipant[] = [
|
||||||
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 0),
|
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 0),
|
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||||
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 0),
|
// Mock Dan to have no published tracks. We want him to still show show up
|
||||||
|
// in the participants list.
|
||||||
|
mockRemoteParticipant({
|
||||||
|
identity: "@dan:example.org:DEV333",
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
getTrackPublications: () => [],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Let's simulate 3 members on the livekitRoom
|
// Let's simulate 3 members on the livekitRoom
|
||||||
@@ -411,24 +412,26 @@ describe("Publishing participants observations", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
participants.forEach((p) =>
|
participants.forEach((p) =>
|
||||||
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, p),
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||||
);
|
);
|
||||||
|
|
||||||
// At this point there should be no publishers
|
// At this point there should be ~~no~~ publishers
|
||||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
// We do have publisher now, since we do not filter for publishers anymore (to also have participants with only data tracks)
|
||||||
|
// The filtering we do is just based on the matrixRTC member events.
|
||||||
|
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||||
|
|
||||||
participants = [
|
participants = [
|
||||||
fakeRemoteLivekitParticipant("@alice:example.org:DEV000", 1),
|
mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }),
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
fakeRemoteLivekitParticipant("@carol:example.org:DEV222", 1),
|
mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }),
|
||||||
fakeRemoteLivekitParticipant("@dan:example.org:DEV333", 2),
|
mockRemoteParticipant({ identity: "@dan:example.org:DEV333" }),
|
||||||
];
|
];
|
||||||
participants.forEach((p) =>
|
participants.forEach((p) =>
|
||||||
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, p),
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p),
|
||||||
);
|
);
|
||||||
|
|
||||||
// At this point there should be no publishers
|
// At this point there should be no publishers
|
||||||
expect(observedPublishers.pop()!.length).toEqual(4);
|
expect(observedParticipants.pop()!.length).toEqual(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should be scoped to parent scope", (): void => {
|
it("should be scoped to parent scope", (): void => {
|
||||||
@@ -436,16 +439,14 @@ describe("Publishing participants observations", () => {
|
|||||||
|
|
||||||
const connection = setupRemoteConnection();
|
const connection = setupRemoteConnection();
|
||||||
|
|
||||||
let observedPublishers: PublishingParticipant[][] = [];
|
let observedParticipants: RemoteParticipant[][] = [];
|
||||||
const s = connection.remoteParticipantsWithTracks$.subscribe(
|
const s = connection.remoteParticipants$.subscribe((participants) => {
|
||||||
(publishers) => {
|
observedParticipants.push(participants);
|
||||||
observedPublishers.push(publishers);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
onTestFinished(() => s.unsubscribe());
|
onTestFinished(() => s.unsubscribe());
|
||||||
|
|
||||||
let participants: RemoteParticipant[] = [
|
let participants: RemoteParticipant[] = [
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 0),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Let's simulate 3 members on the livekitRoom
|
// Let's simulate 3 members on the livekitRoom
|
||||||
@@ -454,38 +455,29 @@ describe("Publishing participants observations", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const participant of participants) {
|
for (const participant of participants) {
|
||||||
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant);
|
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, participant);
|
||||||
}
|
}
|
||||||
|
|
||||||
// At this point there should be no publishers
|
// We should have bob as a participant now
|
||||||
expect(observedPublishers.pop()!.length).toEqual(0);
|
const ps = observedParticipants.pop();
|
||||||
|
expect(ps?.length).toEqual(1);
|
||||||
participants = [fakeRemoteLivekitParticipant("@bob:example.org:DEV111", 1)];
|
expect(ps?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
||||||
|
|
||||||
for (const participant of participants) {
|
|
||||||
fakeRoomEventEmiter.emit(RoomEvent.ParticipantConnected, participant);
|
|
||||||
}
|
|
||||||
|
|
||||||
// We should have bob has a publisher now
|
|
||||||
const publishers = observedPublishers.pop();
|
|
||||||
expect(publishers?.length).toEqual(1);
|
|
||||||
expect(publishers?.[0]?.identity).toEqual("@bob:example.org:DEV111");
|
|
||||||
|
|
||||||
// end the parent scope
|
// end the parent scope
|
||||||
testScope.end();
|
testScope.end();
|
||||||
observedPublishers = [];
|
observedParticipants = [];
|
||||||
|
|
||||||
// SHOULD NOT emit any more publishers as the scope is ended
|
// SHOULD NOT emit any more participants as the scope is ended
|
||||||
participants = participants.filter(
|
participants = participants.filter(
|
||||||
(p) => p.identity !== "@bob:example.org:DEV111",
|
(p) => p.identity !== "@bob:example.org:DEV111",
|
||||||
);
|
);
|
||||||
|
|
||||||
fakeRoomEventEmiter.emit(
|
fakeLivekitRoom.emit(
|
||||||
RoomEvent.ParticipantDisconnected,
|
RoomEvent.ParticipantDisconnected,
|
||||||
fakeRemoteLivekitParticipant("@bob:example.org:DEV111"),
|
mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(observedPublishers.length).toEqual(0);
|
expect(observedParticipants.length).toEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,11 @@ import {
|
|||||||
} from "@livekit/components-core";
|
} from "@livekit/components-core";
|
||||||
import {
|
import {
|
||||||
ConnectionError,
|
ConnectionError,
|
||||||
type ConnectionState as LivekitConenctionState,
|
|
||||||
type Room as LivekitRoom,
|
type Room as LivekitRoom,
|
||||||
type LocalParticipant,
|
|
||||||
type RemoteParticipant,
|
type RemoteParticipant,
|
||||||
RoomEvent,
|
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { BehaviorSubject, map, type Observable } from "rxjs";
|
import { BehaviorSubject, map } from "rxjs";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,12 +27,12 @@ import {
|
|||||||
import { type Behavior } from "../../Behavior.ts";
|
import { type Behavior } from "../../Behavior.ts";
|
||||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||||
import {
|
import {
|
||||||
|
ElementCallError,
|
||||||
InsufficientCapacityError,
|
InsufficientCapacityError,
|
||||||
SFURoomCreationRestrictedError,
|
SFURoomCreationRestrictedError,
|
||||||
|
UnknownCallError,
|
||||||
} from "../../../utils/errors.ts";
|
} from "../../../utils/errors.ts";
|
||||||
|
|
||||||
export type PublishingParticipant = LocalParticipant | RemoteParticipant;
|
|
||||||
|
|
||||||
export interface ConnectionOpts {
|
export interface ConnectionOpts {
|
||||||
/** The media transport to connect to. */
|
/** The media transport to connect to. */
|
||||||
transport: LivekitTransport;
|
transport: LivekitTransport;
|
||||||
@@ -47,19 +44,30 @@ export interface ConnectionOpts {
|
|||||||
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
/** Optional factory to create the LiveKit room, mainly for testing purposes. */
|
||||||
livekitRoomFactory: () => LivekitRoom;
|
livekitRoomFactory: () => LivekitRoom;
|
||||||
}
|
}
|
||||||
|
export class FailedToStartError extends Error {
|
||||||
|
public constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "FailedToStartError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ConnectionState =
|
export enum ConnectionState {
|
||||||
| { state: "Initialized" }
|
/** The start state of a connection. It has been created but nothing has loaded yet. */
|
||||||
| { state: "FetchingConfig"; transport: LivekitTransport }
|
Initialized = "Initialized",
|
||||||
| { state: "ConnectingToLkRoom"; transport: LivekitTransport }
|
/** `start` has been called on the connection. It aquires the jwt info to conenct to the LK Room */
|
||||||
| { state: "PublishingTracks"; transport: LivekitTransport }
|
FetchingConfig = "FetchingConfig",
|
||||||
| { state: "FailedToStart"; error: Error; transport: LivekitTransport }
|
Stopped = "Stopped",
|
||||||
| {
|
/** The same as ConnectionState.Disconnected from `livekit-client` */
|
||||||
state: "ConnectedToLkRoom";
|
LivekitDisconnected = "disconnected",
|
||||||
livekitConnectionState$: Observable<LivekitConenctionState>;
|
/** The same as ConnectionState.Connecting from `livekit-client` */
|
||||||
transport: LivekitTransport;
|
LivekitConnecting = "connecting",
|
||||||
}
|
/** The same as ConnectionState.Connected from `livekit-client` */
|
||||||
| { state: "Stopped"; transport: LivekitTransport };
|
LivekitConnected = "connected",
|
||||||
|
/** The same as ConnectionState.Reconnecting from `livekit-client` */
|
||||||
|
LivekitReconnecting = "reconnecting",
|
||||||
|
/** The same as ConnectionState.SignalReconnecting from `livekit-client` */
|
||||||
|
LivekitSignalReconnecting = "signalReconnecting",
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A connection to a Matrix RTC LiveKit backend.
|
* A connection to a Matrix RTC LiveKit backend.
|
||||||
@@ -68,14 +76,32 @@ export type ConnectionState =
|
|||||||
*/
|
*/
|
||||||
export class Connection {
|
export class Connection {
|
||||||
// Private Behavior
|
// Private Behavior
|
||||||
private readonly _state$ = new BehaviorSubject<ConnectionState>({
|
private readonly _state$ = new BehaviorSubject<
|
||||||
state: "Initialized",
|
ConnectionState | ElementCallError
|
||||||
});
|
>(ConnectionState.Initialized);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current state of the connection to the media transport.
|
* The current state of the connection to the media transport.
|
||||||
*/
|
*/
|
||||||
public readonly state$: Behavior<ConnectionState> = this._state$;
|
public readonly state$: Behavior<ConnectionState | Error> = this._state$;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media transport to connect to.
|
||||||
|
*/
|
||||||
|
public readonly transport: LivekitTransport;
|
||||||
|
|
||||||
|
public readonly livekitRoom: LivekitRoom;
|
||||||
|
|
||||||
|
private scope: ObservableScope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote LiveKit participants that are visible on this connection.
|
||||||
|
*
|
||||||
|
* Note that this may include participants that are connected only to
|
||||||
|
* subscribe, or publishers that are otherwise unattested in MatrixRTC state.
|
||||||
|
* It is therefore more low-level than what should be presented to the user.
|
||||||
|
*/
|
||||||
|
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the connection has been stopped.
|
* Whether the connection has been stopped.
|
||||||
@@ -96,24 +122,29 @@ export class Connection {
|
|||||||
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
|
* @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection.
|
||||||
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
|
* @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created.
|
||||||
*/
|
*/
|
||||||
// TODO dont make this throw and instead store a connection error state in this class?
|
|
||||||
// TODO consider an autostart pattern...
|
// TODO consider an autostart pattern...
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
this.logger.debug("Starting Connection");
|
this.logger.debug("Starting Connection");
|
||||||
this.stopped = false;
|
this.stopped = false;
|
||||||
try {
|
try {
|
||||||
this._state$.next({
|
this._state$.next(ConnectionState.FetchingConfig);
|
||||||
state: "FetchingConfig",
|
// We should already have this information after creating the localTransport.
|
||||||
transport: this.transport,
|
// It would probably be better to forward this here.
|
||||||
});
|
|
||||||
const { url, jwt } = await this.getSFUConfigWithOpenID();
|
const { url, jwt } = await this.getSFUConfigWithOpenID();
|
||||||
// If we were stopped while fetching the config, don't proceed to connect
|
// If we were stopped while fetching the config, don't proceed to connect
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
|
|
||||||
this._state$.next({
|
// Setup observer once we are done with getSFUConfigWithOpenID
|
||||||
state: "ConnectingToLkRoom",
|
connectionStateObserver(this.livekitRoom)
|
||||||
transport: this.transport,
|
.pipe(
|
||||||
});
|
this.scope.bind(),
|
||||||
|
map((s) => s as unknown as ConnectionState),
|
||||||
|
)
|
||||||
|
.subscribe((lkState) => {
|
||||||
|
// It is save to cast lkState to ConnectionState as they are fully overlapping.
|
||||||
|
this._state$.next(lkState);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.livekitRoom.connect(url, jwt);
|
await this.livekitRoom.connect(url, jwt);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -128,7 +159,8 @@ export class Connection {
|
|||||||
throw new InsufficientCapacityError();
|
throw new InsufficientCapacityError();
|
||||||
}
|
}
|
||||||
if (e.status === 404) {
|
if (e.status === 404) {
|
||||||
// error msg is "Could not establish signal connection: requested room does not exist"
|
// error msg is "Failed to create call"
|
||||||
|
// error description is "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists."
|
||||||
// The room does not exist. There are two different modes of operation for the SFU:
|
// The room does not exist. There are two different modes of operation for the SFU:
|
||||||
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
||||||
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||||
@@ -140,19 +172,16 @@ export class Connection {
|
|||||||
}
|
}
|
||||||
// If we were stopped while connecting, don't proceed to update state.
|
// If we were stopped while connecting, don't proceed to update state.
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
|
|
||||||
this._state$.next({
|
|
||||||
state: "ConnectedToLkRoom",
|
|
||||||
transport: this.transport,
|
|
||||||
livekitConnectionState$: connectionStateObserver(this.livekitRoom),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
this.logger.debug(`Failed to connect to LiveKit room: ${error}`);
|
||||||
this._state$.next({
|
this._state$.next(
|
||||||
state: "FailedToStart",
|
error instanceof ElementCallError
|
||||||
error: error instanceof Error ? error : new Error(`${error}`),
|
? error
|
||||||
transport: this.transport,
|
: error instanceof Error
|
||||||
});
|
? new UnknownCallError(error)
|
||||||
|
: new UnknownCallError(new Error(`${error}`)),
|
||||||
|
);
|
||||||
|
// Its okay to ignore the throw. The error is part of the state.
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,30 +206,11 @@ export class Connection {
|
|||||||
);
|
);
|
||||||
if (this.stopped) return;
|
if (this.stopped) return;
|
||||||
await this.livekitRoom.disconnect();
|
await this.livekitRoom.disconnect();
|
||||||
this._state$.next({
|
this._state$.next(ConnectionState.Stopped);
|
||||||
state: "Stopped",
|
|
||||||
transport: this.transport,
|
|
||||||
});
|
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An observable of the participants that are publishing on this connection. (Excluding our local participant)
|
|
||||||
* This is derived from `participantsIncludingSubscribers$` and `remoteTransports$`.
|
|
||||||
* It filters the participants to only those that are associated with a membership that claims to publish on this connection.
|
|
||||||
*/
|
|
||||||
public readonly remoteParticipantsWithTracks$: Behavior<
|
|
||||||
PublishingParticipant[]
|
|
||||||
>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The media transport to connect to.
|
|
||||||
*/
|
|
||||||
public readonly transport: LivekitTransport;
|
|
||||||
|
|
||||||
private readonly client: OpenIDClientParts;
|
private readonly client: OpenIDClientParts;
|
||||||
public readonly livekitRoom: LivekitRoom;
|
|
||||||
|
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,36 +218,23 @@ export class Connection {
|
|||||||
*
|
*
|
||||||
* @param opts - Connection options {@link ConnectionOpts}.
|
* @param opts - Connection options {@link ConnectionOpts}.
|
||||||
*
|
*
|
||||||
* @param logger
|
* @param logger - The logger to use.
|
||||||
*/
|
*/
|
||||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||||
this.logger = logger.getChild("[Connection]");
|
this.logger = logger.getChild("[Connection]");
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
`[Connection] Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`,
|
`Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`,
|
||||||
);
|
);
|
||||||
const { transport, client, scope } = opts;
|
const { transport, client, scope } = opts;
|
||||||
|
|
||||||
|
this.scope = scope;
|
||||||
this.livekitRoom = opts.livekitRoomFactory();
|
this.livekitRoom = opts.livekitRoomFactory();
|
||||||
this.transport = transport;
|
this.transport = transport;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
|
|
||||||
// REMOTE participants with track!!!
|
this.remoteParticipants$ = scope.behavior(
|
||||||
// this.remoteParticipantsWithTracks$
|
// Only tracks remote participants
|
||||||
this.remoteParticipantsWithTracks$ = scope.behavior(
|
connectedParticipantsObserver(this.livekitRoom),
|
||||||
// only tracks remote participants
|
|
||||||
connectedParticipantsObserver(this.livekitRoom, {
|
|
||||||
additionalRoomEvents: [
|
|
||||||
RoomEvent.TrackPublished,
|
|
||||||
RoomEvent.TrackUnpublished,
|
|
||||||
],
|
|
||||||
}).pipe(
|
|
||||||
map((participants) => {
|
|
||||||
return participants.filter(
|
|
||||||
(participant) => participant.getTrackPublications().length > 0,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
[],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
scope.onEnd(() => {
|
scope.onEnd(() => {
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import {
|
import {
|
||||||
type E2EEOptions,
|
|
||||||
Room as LivekitRoom,
|
Room as LivekitRoom,
|
||||||
type RoomOptions,
|
type RoomOptions,
|
||||||
type BaseKeyProvider,
|
type BaseKeyProvider,
|
||||||
|
type E2EEManagerOptions,
|
||||||
|
type BaseE2EEManager,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
// imported as inline to support worker when loaded from a cdn (cross domain)
|
||||||
|
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
||||||
|
|
||||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||||
import { Connection } from "./Connection.ts";
|
import { Connection } from "./Connection.ts";
|
||||||
@@ -41,9 +43,11 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
||||||
* @param devices - Used for video/audio out/in capture options.
|
* @param devices - Used for video/audio out/in capture options.
|
||||||
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
||||||
* @param e2eeLivekitOptions - The E2EE options to use for the LiveKit Room.
|
* @param livekitKeyProvider - Optional key provider for end-to-end encryption.
|
||||||
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
|
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
|
||||||
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
|
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
|
||||||
|
* @param echoCancellation - Whether to enable echo cancellation for audio capture.
|
||||||
|
* @param noiseSuppression - Whether to enable noise suppression for audio capture.
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor(
|
||||||
private client: OpenIDClientParts,
|
private client: OpenIDClientParts,
|
||||||
@@ -52,20 +56,24 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
livekitKeyProvider: BaseKeyProvider | undefined,
|
livekitKeyProvider: BaseKeyProvider | undefined,
|
||||||
private controlledAudioDevices: boolean,
|
private controlledAudioDevices: boolean,
|
||||||
livekitRoomFactory?: () => LivekitRoom,
|
livekitRoomFactory?: () => LivekitRoom,
|
||||||
|
echoCancellation: boolean = true,
|
||||||
|
noiseSuppression: boolean = true,
|
||||||
) {
|
) {
|
||||||
const defaultFactory = (): LivekitRoom =>
|
const defaultFactory = (): LivekitRoom =>
|
||||||
new LivekitRoom(
|
new LivekitRoom(
|
||||||
generateRoomOption(
|
generateRoomOption({
|
||||||
this.devices,
|
devices: this.devices,
|
||||||
this.processorState$.value,
|
processorState: this.processorState$.value,
|
||||||
livekitKeyProvider && {
|
e2eeLivekitOptions: livekitKeyProvider && {
|
||||||
keyProvider: livekitKeyProvider,
|
keyProvider: livekitKeyProvider,
|
||||||
// It's important that every room use a separate E2EE worker.
|
// It's important that every room use a separate E2EE worker.
|
||||||
// They get confused if given streams from multiple rooms.
|
// They get confused if given streams from multiple rooms.
|
||||||
worker: new E2EEWorker(),
|
worker: new E2EEWorker(),
|
||||||
},
|
},
|
||||||
this.controlledAudioDevices,
|
controlledAudioDevices: this.controlledAudioDevices,
|
||||||
),
|
echoCancellation,
|
||||||
|
noiseSuppression,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
||||||
}
|
}
|
||||||
@@ -90,12 +98,24 @@ export class ECConnectionFactory implements ConnectionFactory {
|
|||||||
/**
|
/**
|
||||||
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
|
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
|
||||||
*/
|
*/
|
||||||
function generateRoomOption(
|
function generateRoomOption({
|
||||||
devices: MediaDevices,
|
devices,
|
||||||
processorState: ProcessorState,
|
processorState,
|
||||||
e2eeLivekitOptions: E2EEOptions | undefined,
|
e2eeLivekitOptions,
|
||||||
controlledAudioDevices: boolean,
|
controlledAudioDevices,
|
||||||
): RoomOptions {
|
echoCancellation,
|
||||||
|
noiseSuppression,
|
||||||
|
}: {
|
||||||
|
devices: MediaDevices;
|
||||||
|
processorState: ProcessorState;
|
||||||
|
e2eeLivekitOptions:
|
||||||
|
| E2EEManagerOptions
|
||||||
|
| { e2eeManager: BaseE2EEManager }
|
||||||
|
| undefined;
|
||||||
|
controlledAudioDevices: boolean;
|
||||||
|
echoCancellation: boolean;
|
||||||
|
noiseSuppression: boolean;
|
||||||
|
}): RoomOptions {
|
||||||
return {
|
return {
|
||||||
...defaultLiveKitOptions,
|
...defaultLiveKitOptions,
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
@@ -106,6 +126,8 @@ function generateRoomOption(
|
|||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||||
deviceId: devices.audioInput.selected$.value?.id,
|
deviceId: devices.audioInput.selected$.value?.id,
|
||||||
|
echoCancellation,
|
||||||
|
noiseSuppression,
|
||||||
},
|
},
|
||||||
audioOutput: {
|
audioOutput: {
|
||||||
// When using controlled audio devices, we don't want to set the
|
// When using controlled audio devices, we don't want to set the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { type Participant as LivekitParticipant } from "livekit-client";
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
|
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
|
||||||
@@ -52,7 +52,7 @@ beforeEach(() => {
|
|||||||
(transport: LivekitTransport, scope: ObservableScope) => {
|
(transport: LivekitTransport, scope: ObservableScope) => {
|
||||||
const mockConnection = {
|
const mockConnection = {
|
||||||
transport,
|
transport,
|
||||||
remoteParticipantsWithTracks$: new BehaviorSubject([]),
|
remoteParticipants$: new BehaviorSubject([]),
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
vi.mocked(mockConnection).start = vi.fn();
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
vi.mocked(mockConnection).stop = vi.fn();
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
@@ -200,24 +200,21 @@ describe("connections$ stream", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("connectionManagerData$ stream", () => {
|
describe("connectionManagerData$ stream", () => {
|
||||||
// Used in test to control fake connections' remoteParticipantsWithTracks$ streams
|
// Used in test to control fake connections' remoteParticipants$ streams
|
||||||
let fakePublishingParticipantsStreams: Map<
|
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||||
string,
|
|
||||||
Behavior<LivekitParticipant[]>
|
|
||||||
>;
|
|
||||||
|
|
||||||
function keyForTransport(transport: LivekitTransport): string {
|
function keyForTransport(transport: LivekitTransport): string {
|
||||||
return `${transport.livekit_service_url}|${transport.livekit_alias}`;
|
return `${transport.livekit_service_url}|${transport.livekit_alias}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fakePublishingParticipantsStreams = new Map();
|
fakeRemoteParticipantsStreams = new Map();
|
||||||
|
|
||||||
function getPublishingParticipantsFor(
|
function getRemoteParticipantsFor(
|
||||||
transport: LivekitTransport,
|
transport: LivekitTransport,
|
||||||
): Behavior<LivekitParticipant[]> {
|
): Behavior<RemoteParticipant[]> {
|
||||||
return (
|
return (
|
||||||
fakePublishingParticipantsStreams.get(keyForTransport(transport)) ??
|
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
|
||||||
new BehaviorSubject([])
|
new BehaviorSubject([])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,13 +224,12 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
(transport: LivekitTransport, scope: ObservableScope) => {
|
(transport: LivekitTransport, scope: ObservableScope) => {
|
||||||
const fakePublishingParticipants$ = new BehaviorSubject<
|
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||||
LivekitParticipant[]
|
RemoteParticipant[]
|
||||||
>([]);
|
>([]);
|
||||||
const mockConnection = {
|
const mockConnection = {
|
||||||
transport,
|
transport,
|
||||||
remoteParticipantsWithTracks$:
|
remoteParticipants$: getRemoteParticipantsFor(transport),
|
||||||
getPublishingParticipantsFor(transport),
|
|
||||||
} as unknown as Connection;
|
} as unknown as Connection;
|
||||||
vi.mocked(mockConnection).start = vi.fn();
|
vi.mocked(mockConnection).start = vi.fn();
|
||||||
vi.mocked(mockConnection).stop = vi.fn();
|
vi.mocked(mockConnection).stop = vi.fn();
|
||||||
@@ -242,36 +238,36 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
void mockConnection.stop();
|
void mockConnection.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
fakePublishingParticipantsStreams.set(
|
fakeRemoteParticipantsStreams.set(
|
||||||
keyForTransport(transport),
|
keyForTransport(transport),
|
||||||
fakePublishingParticipants$,
|
fakeRemoteParticipants$,
|
||||||
);
|
);
|
||||||
return mockConnection;
|
return mockConnection;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Should report connections with the publishing participants", () => {
|
test("Should report connections with the remote participants", () => {
|
||||||
withTestScheduler(({ expectObservable, schedule, behavior }) => {
|
withTestScheduler(({ expectObservable, schedule, behavior }) => {
|
||||||
// Setup the fake participants streams behavior
|
// Setup the fake participants streams behavior
|
||||||
// ==============================
|
// ==============================
|
||||||
fakePublishingParticipantsStreams.set(
|
fakeRemoteParticipantsStreams.set(
|
||||||
keyForTransport(TRANSPORT_1),
|
keyForTransport(TRANSPORT_1),
|
||||||
behavior("oa-b", {
|
behavior("oa-b", {
|
||||||
o: [],
|
o: [],
|
||||||
a: [{ identity: "user1A" } as LivekitParticipant],
|
a: [{ identity: "user1A" } as RemoteParticipant],
|
||||||
b: [
|
b: [
|
||||||
{ identity: "user1A" } as LivekitParticipant,
|
{ identity: "user1A" } as RemoteParticipant,
|
||||||
{ identity: "user1B" } as LivekitParticipant,
|
{ identity: "user1B" } as RemoteParticipant,
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
fakePublishingParticipantsStreams.set(
|
fakeRemoteParticipantsStreams.set(
|
||||||
keyForTransport(TRANSPORT_2),
|
keyForTransport(TRANSPORT_2),
|
||||||
behavior("o-a", {
|
behavior("o-a", {
|
||||||
o: [],
|
o: [],
|
||||||
a: [{ identity: "user2A" } as LivekitParticipant],
|
a: [{ identity: "user2A" } as RemoteParticipant],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// ==============================
|
// ==============================
|
||||||
@@ -289,47 +285,47 @@ describe("connectionManagerData$ stream", () => {
|
|||||||
a: expect.toSatisfy((e) => {
|
a: expect.toSatisfy((e) => {
|
||||||
const data: ConnectionManagerData = e.value;
|
const data: ConnectionManagerData = e.value;
|
||||||
expect(data.getConnections().length).toBe(2);
|
expect(data.getConnections().length).toBe(2);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(0);
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0);
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
b: expect.toSatisfy((e) => {
|
b: expect.toSatisfy((e) => {
|
||||||
const data: ConnectionManagerData = e.value;
|
const data: ConnectionManagerData = e.value;
|
||||||
expect(data.getConnections().length).toBe(2);
|
expect(data.getConnections().length).toBe(2);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1);
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0);
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe(
|
expect(
|
||||||
"user1A",
|
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||||
);
|
).toBe("user1A");
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
c: expect.toSatisfy((e) => {
|
c: expect.toSatisfy((e) => {
|
||||||
const data: ConnectionManagerData = e.value;
|
const data: ConnectionManagerData = e.value;
|
||||||
expect(data.getConnections().length).toBe(2);
|
expect(data.getConnections().length).toBe(2);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1);
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1);
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe(
|
expect(
|
||||||
"user1A",
|
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||||
);
|
).toBe("user1A");
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe(
|
expect(
|
||||||
"user2A",
|
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
|
||||||
);
|
).toBe("user2A");
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
d: expect.toSatisfy((e) => {
|
d: expect.toSatisfy((e) => {
|
||||||
const data: ConnectionManagerData = e.value;
|
const data: ConnectionManagerData = e.value;
|
||||||
expect(data.getConnections().length).toBe(2);
|
expect(data.getConnections().length).toBe(2);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(2);
|
expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1);
|
expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1);
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe(
|
expect(
|
||||||
"user1A",
|
data.getParticipantsForTransport(TRANSPORT_1)[0].identity,
|
||||||
);
|
).toBe("user1A");
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_1)[1].identity).toBe(
|
expect(
|
||||||
"user1B",
|
data.getParticipantsForTransport(TRANSPORT_1)[1].identity,
|
||||||
);
|
).toBe("user1B");
|
||||||
expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe(
|
expect(
|
||||||
"user2A",
|
data.getParticipantsForTransport(TRANSPORT_2)[0].identity,
|
||||||
);
|
).toBe("user2A");
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
type LivekitTransport,
|
import { combineLatest, map, of, switchMap, tap } from "rxjs";
|
||||||
type ParticipantId,
|
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
|
||||||
import { BehaviorSubject, combineLatest, map, of, switchMap, tap } from "rxjs";
|
|
||||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
import { type RemoteParticipant } from "livekit-client";
|
||||||
|
|
||||||
import { type Behavior } from "../../Behavior.ts";
|
import { type Behavior } from "../../Behavior.ts";
|
||||||
import { type Connection } from "./Connection.ts";
|
import { type Connection } from "./Connection.ts";
|
||||||
@@ -24,21 +21,18 @@ import { type ConnectionFactory } from "./ConnectionFactory.ts";
|
|||||||
export class ConnectionManagerData {
|
export class ConnectionManagerData {
|
||||||
private readonly store: Map<
|
private readonly store: Map<
|
||||||
string,
|
string,
|
||||||
[Connection, (LocalParticipant | RemoteParticipant)[]]
|
{ connection: Connection; participants: RemoteParticipant[] }
|
||||||
> = new Map();
|
> = new Map();
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public add(
|
public add(connection: Connection, participants: RemoteParticipant[]): void {
|
||||||
connection: Connection,
|
|
||||||
participants: (LocalParticipant | RemoteParticipant)[],
|
|
||||||
): void {
|
|
||||||
const key = this.getKey(connection.transport);
|
const key = this.getKey(connection.transport);
|
||||||
const existing = this.store.get(key);
|
const existing = this.store.get(key);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
this.store.set(key, [connection, participants]);
|
this.store.set(key, { connection, participants });
|
||||||
} else {
|
} else {
|
||||||
existing[1].push(...participants);
|
existing.participants.push(...participants);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,59 +41,46 @@ export class ConnectionManagerData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getConnections(): Connection[] {
|
public getConnections(): Connection[] {
|
||||||
return Array.from(this.store.values()).map(([connection]) => connection);
|
return Array.from(this.store.values()).map(({ connection }) => connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getConnectionForTransport(
|
public getConnectionForTransport(
|
||||||
transport: LivekitTransport,
|
transport: LivekitTransport,
|
||||||
): Connection | null {
|
): Connection | null {
|
||||||
return this.store.get(this.getKey(transport))?.[0] ?? null;
|
return this.store.get(this.getKey(transport))?.connection ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getParticipantForTransport(
|
public getParticipantsForTransport(
|
||||||
transport: LivekitTransport,
|
transport: LivekitTransport,
|
||||||
): (LocalParticipant | RemoteParticipant)[] {
|
): RemoteParticipant[] {
|
||||||
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||||
const existing = this.store.get(key);
|
const existing = this.store.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing[1];
|
return existing.participants;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* Get all connections where the given participant is publishing.
|
|
||||||
* In theory, there could be several connections where the same participant is publishing but with
|
|
||||||
* only well behaving clients a participant should only be publishing on a single connection.
|
|
||||||
* @param participantId
|
|
||||||
*/
|
|
||||||
public getConnectionsForParticipant(
|
|
||||||
participantId: ParticipantId,
|
|
||||||
): Connection[] {
|
|
||||||
const connections: Connection[] = [];
|
|
||||||
for (const [connection, participants] of this.store.values()) {
|
|
||||||
if (participants.some((p) => p.identity === participantId)) {
|
|
||||||
connections.push(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return connections;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
connectionFactory: ConnectionFactory;
|
connectionFactory: ConnectionFactory;
|
||||||
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
inputTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO - write test for scopes (do we really need to bind scope)
|
// TODO - write test for scopes (do we really need to bind scope)
|
||||||
export interface IConnectionManager {
|
export interface IConnectionManager {
|
||||||
transports$: Behavior<Epoch<LivekitTransport[]>>;
|
|
||||||
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
connectionManagerData$: Behavior<Epoch<ConnectionManagerData>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crete a `ConnectionManager`
|
* Crete a `ConnectionManager`
|
||||||
* @param scope the observable scope used by this object.
|
* @param props - Configuration object
|
||||||
* @param connectionFactory used to create new connections.
|
* @param props.scope - The observable scope used by this object
|
||||||
* @param _transportsSubscriptions$ A list of Behaviors each containing a LIST of LivekitTransport.
|
* @param props.connectionFactory - Used to create new connections
|
||||||
|
* @param props.inputTransports$ - A list of Behaviors each containing a LIST of LivekitTransport.
|
||||||
|
* @param props.logger - The logger to use
|
||||||
* Each of these behaviors can be interpreted as subscribed list of transports.
|
* Each of these behaviors can be interpreted as subscribed list of transports.
|
||||||
*
|
*
|
||||||
* Using `registerTransports` independent external modules can control what connections
|
* Using `registerTransports` independent external modules can control what connections
|
||||||
@@ -116,9 +97,6 @@ export function createConnectionManager$({
|
|||||||
logger: parentLogger,
|
logger: parentLogger,
|
||||||
}: Props): IConnectionManager {
|
}: Props): IConnectionManager {
|
||||||
const logger = parentLogger.getChild("[ConnectionManager]");
|
const logger = parentLogger.getChild("[ConnectionManager]");
|
||||||
|
|
||||||
const running$ = new BehaviorSubject(true);
|
|
||||||
scope.onEnd(() => running$.next(false));
|
|
||||||
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
// TODO logger: only construct one logger from the client and make it compatible via a EC specific sing
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,10 +108,7 @@ export function createConnectionManager$({
|
|||||||
* externally this is modified via `registerTransports()`.
|
* externally this is modified via `registerTransports()`.
|
||||||
*/
|
*/
|
||||||
const transports$ = scope.behavior(
|
const transports$ = scope.behavior(
|
||||||
combineLatest([running$, inputTransports$]).pipe(
|
inputTransports$.pipe(
|
||||||
map(([running, transports]) =>
|
|
||||||
transports.mapInner((transport) => (running ? transport : [])),
|
|
||||||
),
|
|
||||||
map((transports) => transports.mapInner(removeDuplicateTransports)),
|
map((transports) => transports.mapInner(removeDuplicateTransports)),
|
||||||
tap(({ value: transports }) => {
|
tap(({ value: transports }) => {
|
||||||
logger.trace(
|
logger.trace(
|
||||||
@@ -183,23 +158,25 @@ export function createConnectionManager$({
|
|||||||
const epoch = connections.epoch;
|
const epoch = connections.epoch;
|
||||||
|
|
||||||
// Map the connections to list of {connection, participants}[]
|
// Map the connections to list of {connection, participants}[]
|
||||||
const listOfConnectionsWithPublishingParticipants =
|
const listOfConnectionsWithRemoteParticipants = connections.value.map(
|
||||||
connections.value.map((connection) => {
|
(connection) => {
|
||||||
return connection.remoteParticipantsWithTracks$.pipe(
|
return connection.remoteParticipants$.pipe(
|
||||||
map((participants) => ({
|
map((participants) => ({
|
||||||
connection,
|
connection,
|
||||||
participants,
|
participants,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// probably not required
|
// probably not required
|
||||||
if (listOfConnectionsWithPublishingParticipants.length === 0) {
|
|
||||||
|
if (listOfConnectionsWithRemoteParticipants.length === 0) {
|
||||||
return of(new Epoch(new ConnectionManagerData(), epoch));
|
return of(new Epoch(new ConnectionManagerData(), epoch));
|
||||||
}
|
}
|
||||||
|
|
||||||
// combineLatest the several streams into a single stream with the ConnectionManagerData
|
// combineLatest the several streams into a single stream with the ConnectionManagerData
|
||||||
return combineLatest(listOfConnectionsWithPublishingParticipants).pipe(
|
return combineLatest(listOfConnectionsWithRemoteParticipants).pipe(
|
||||||
map(
|
map(
|
||||||
(lists) =>
|
(lists) =>
|
||||||
new Epoch(
|
new Epoch(
|
||||||
@@ -216,7 +193,7 @@ export function createConnectionManager$({
|
|||||||
new Epoch(new ConnectionManagerData()),
|
new Epoch(new ConnectionManagerData()),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { transports$, connectionManagerData$ };
|
return { connectionManagerData$ };
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDuplicateTransports(
|
function removeDuplicateTransports(
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations 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, describe, expect, test, vi } from "vitest";
|
||||||
|
import { Room as LivekitRoom } from "livekit-client";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import fetchMock from "fetch-mock";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import EventEmitter from "events";
|
||||||
|
|
||||||
|
import { ObservableScope } from "../../ObservableScope.ts";
|
||||||
|
import { ECConnectionFactory } from "./ConnectionFactory.ts";
|
||||||
|
import type { OpenIDClientParts } from "../../../livekit/openIDSFU.ts";
|
||||||
|
import { exampleTransport, mockMediaDevices } from "../../../utils/test.ts";
|
||||||
|
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||||
|
import { constant } from "../../Behavior";
|
||||||
|
|
||||||
|
// At the top of your test file, after imports
|
||||||
|
vi.mock("livekit-client", async (importOriginal) => {
|
||||||
|
return {
|
||||||
|
...(await importOriginal()),
|
||||||
|
Room: vi.fn().mockImplementation(function (this: LivekitRoom, options) {
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
return {
|
||||||
|
on: emitter.on.bind(emitter),
|
||||||
|
off: emitter.off.bind(emitter),
|
||||||
|
emit: emitter.emit.bind(emitter),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
remoteParticipants: new Map(),
|
||||||
|
} as unknown as LivekitRoom;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let testScope: ObservableScope;
|
||||||
|
let mockClient: OpenIDClientParts;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testScope = new ObservableScope();
|
||||||
|
mockClient = {
|
||||||
|
getOpenIdToken: vi.fn().mockReturnValue(""),
|
||||||
|
getDeviceId: vi.fn().mockReturnValue("DEV000"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ECConnectionFactory - Audio inputs options", () => {
|
||||||
|
test.each([
|
||||||
|
{ echo: true, noise: true },
|
||||||
|
{ echo: true, noise: false },
|
||||||
|
{ echo: false, noise: true },
|
||||||
|
{ echo: false, noise: false },
|
||||||
|
])(
|
||||||
|
"it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters",
|
||||||
|
({ echo, noise }) => {
|
||||||
|
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
|
||||||
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
|
mockClient,
|
||||||
|
mockMediaDevices({}),
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
supported: true,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
echo,
|
||||||
|
noise,
|
||||||
|
);
|
||||||
|
ecConnectionFactory.createConnection(exampleTransport, testScope, logger);
|
||||||
|
|
||||||
|
// Check if Room was constructed with expected options
|
||||||
|
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
audioCaptureDefaults: expect.objectContaining({
|
||||||
|
echoCancellation: echo,
|
||||||
|
noiseSuppression: noise,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
||||||
|
test.each([{ controlled: true }, { controlled: false }])(
|
||||||
|
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
|
||||||
|
({ controlled }) => {
|
||||||
|
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
|
||||||
|
const RoomConstructor = vi.mocked(LivekitRoom);
|
||||||
|
|
||||||
|
const ecConnectionFactory = new ECConnectionFactory(
|
||||||
|
mockClient,
|
||||||
|
mockMediaDevices({
|
||||||
|
audioOutput: {
|
||||||
|
available$: constant(new Map<never, never>()),
|
||||||
|
selected$: constant({ id: "DEV00", virtualEarpiece: false }),
|
||||||
|
select: () => {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new BehaviorSubject<ProcessorState>({
|
||||||
|
supported: true,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
undefined,
|
||||||
|
controlled,
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
ecConnectionFactory.createConnection(exampleTransport, testScope, logger);
|
||||||
|
|
||||||
|
// Check if Room was constructed with expected options
|
||||||
|
expect(RoomConstructor).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
audioOutput: expect.objectContaining({
|
||||||
|
deviceId: controlled ? undefined : "DEV00",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testScope.end();
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
@@ -15,7 +15,7 @@ import { combineLatest, map, type Observable } from "rxjs";
|
|||||||
|
|
||||||
import { type IConnectionManager } from "./ConnectionManager.ts";
|
import { type IConnectionManager } from "./ConnectionManager.ts";
|
||||||
import {
|
import {
|
||||||
type MatrixLivekitMember,
|
type RemoteMatrixLivekitMember,
|
||||||
createMatrixLivekitMembers$,
|
createMatrixLivekitMembers$,
|
||||||
} from "./MatrixLivekitMembers.ts";
|
} from "./MatrixLivekitMembers.ts";
|
||||||
import {
|
import {
|
||||||
@@ -91,7 +91,7 @@ test("should signal participant not yet connected to livekit", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const matrixLivekitMember$ = createMatrixLivekitMembers$({
|
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||||
scope: testScope,
|
scope: testScope,
|
||||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||||
connectionManager: {
|
connectionManager: {
|
||||||
@@ -99,21 +99,24 @@ test("should signal participant not yet connected to livekit", () => {
|
|||||||
} as unknown as IConnectionManager,
|
} as unknown as IConnectionManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
|
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
|
||||||
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
|
"a",
|
||||||
expect(data.length).toEqual(1);
|
{
|
||||||
expectObservable(data[0].membership$).toBe("a", {
|
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
|
||||||
a: bobMembership,
|
expect(data.length).toEqual(1);
|
||||||
});
|
expectObservable(data[0].membership$).toBe("a", {
|
||||||
expectObservable(data[0].participant$).toBe("a", {
|
a: bobMembership,
|
||||||
a: null,
|
});
|
||||||
});
|
expectObservable(data[0].participant.value$).toBe("a", {
|
||||||
expectObservable(data[0].connection$).toBe("a", {
|
a: null,
|
||||||
a: null,
|
});
|
||||||
});
|
expectObservable(data[0].connection$).toBe("a", {
|
||||||
return true;
|
a: null,
|
||||||
}),
|
});
|
||||||
});
|
return true;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,7 +174,7 @@ test("should signal participant on a connection that is publishing", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const matrixLivekitMember$ = createMatrixLivekitMembers$({
|
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||||
scope: testScope,
|
scope: testScope,
|
||||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||||
connectionManager: {
|
connectionManager: {
|
||||||
@@ -179,25 +182,28 @@ test("should signal participant on a connection that is publishing", () => {
|
|||||||
} as unknown as IConnectionManager,
|
} as unknown as IConnectionManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
|
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
|
||||||
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
|
"a",
|
||||||
expect(data.length).toEqual(1);
|
{
|
||||||
expectObservable(data[0].membership$).toBe("a", {
|
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
|
||||||
a: bobMembership,
|
expect(data.length).toEqual(1);
|
||||||
});
|
expectObservable(data[0].membership$).toBe("a", {
|
||||||
expectObservable(data[0].participant$).toBe("a", {
|
a: bobMembership,
|
||||||
a: expect.toSatisfy((participant) => {
|
});
|
||||||
expect(participant).toBeDefined();
|
expectObservable(data[0].participant.value$).toBe("a", {
|
||||||
expect(participant!.identity).toEqual(bobParticipantId);
|
a: expect.toSatisfy((participant) => {
|
||||||
return true;
|
expect(participant).toBeDefined();
|
||||||
}),
|
expect(participant!.identity).toEqual(bobParticipantId);
|
||||||
});
|
return true;
|
||||||
expectObservable(data[0].connection$).toBe("a", {
|
}),
|
||||||
a: connection,
|
});
|
||||||
});
|
expectObservable(data[0].connection$).toBe("a", {
|
||||||
return true;
|
a: connection,
|
||||||
}),
|
});
|
||||||
});
|
return true;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,7 +228,7 @@ test("should signal participant on a connection that is not publishing", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const matrixLivekitMember$ = createMatrixLivekitMembers$({
|
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||||
scope: testScope,
|
scope: testScope,
|
||||||
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
membershipsWithTransport$: testScope.behavior(membershipsWithTransport$),
|
||||||
connectionManager: {
|
connectionManager: {
|
||||||
@@ -230,21 +236,24 @@ test("should signal participant on a connection that is not publishing", () => {
|
|||||||
} as unknown as IConnectionManager,
|
} as unknown as IConnectionManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", {
|
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
|
||||||
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
|
"a",
|
||||||
expect(data.length).toEqual(1);
|
{
|
||||||
expectObservable(data[0].membership$).toBe("a", {
|
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
|
||||||
a: bobMembership,
|
expect(data.length).toEqual(1);
|
||||||
});
|
expectObservable(data[0].membership$).toBe("a", {
|
||||||
expectObservable(data[0].participant$).toBe("a", {
|
a: bobMembership,
|
||||||
a: null,
|
});
|
||||||
});
|
expectObservable(data[0].participant.value$).toBe("a", {
|
||||||
expectObservable(data[0].connection$).toBe("a", {
|
a: null,
|
||||||
a: connection,
|
});
|
||||||
});
|
expectObservable(data[0].connection$).toBe("a", {
|
||||||
return true;
|
a: connection,
|
||||||
}),
|
});
|
||||||
});
|
return true;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,7 +292,7 @@ describe("Publication edge case", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const matrixLivekitMember$ = createMatrixLivekitMembers$({
|
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||||
scope: testScope,
|
scope: testScope,
|
||||||
membershipsWithTransport$: testScope.behavior(
|
membershipsWithTransport$: testScope.behavior(
|
||||||
membershipsWithTransport$,
|
membershipsWithTransport$,
|
||||||
@@ -293,10 +302,10 @@ describe("Publication edge case", () => {
|
|||||||
} as unknown as IConnectionManager,
|
} as unknown as IConnectionManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe(
|
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
|
||||||
"a",
|
"a",
|
||||||
{
|
{
|
||||||
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
|
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
|
||||||
expect(data.length).toEqual(2);
|
expect(data.length).toEqual(2);
|
||||||
expectObservable(data[0].membership$).toBe("a", {
|
expectObservable(data[0].membership$).toBe("a", {
|
||||||
a: bobMembership,
|
a: bobMembership,
|
||||||
@@ -305,7 +314,7 @@ describe("Publication edge case", () => {
|
|||||||
// The real connection should be from transportA as per the membership
|
// The real connection should be from transportA as per the membership
|
||||||
a: connectionA,
|
a: connectionA,
|
||||||
});
|
});
|
||||||
expectObservable(data[0].participant$).toBe("a", {
|
expectObservable(data[0].participant.value$).toBe("a", {
|
||||||
a: expect.toSatisfy((participant) => {
|
a: expect.toSatisfy((participant) => {
|
||||||
expect(participant).toBeDefined();
|
expect(participant).toBeDefined();
|
||||||
expect(participant!.identity).toEqual(bobParticipantId);
|
expect(participant!.identity).toEqual(bobParticipantId);
|
||||||
@@ -349,7 +358,7 @@ describe("Publication edge case", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const matrixLivekitMember$ = createMatrixLivekitMembers$({
|
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||||
scope: testScope,
|
scope: testScope,
|
||||||
membershipsWithTransport$: testScope.behavior(
|
membershipsWithTransport$: testScope.behavior(
|
||||||
membershipsWithTransport$,
|
membershipsWithTransport$,
|
||||||
@@ -359,10 +368,10 @@ describe("Publication edge case", () => {
|
|||||||
} as unknown as IConnectionManager,
|
} as unknown as IConnectionManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe(
|
expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe(
|
||||||
"a",
|
"a",
|
||||||
{
|
{
|
||||||
a: expect.toSatisfy((data: MatrixLivekitMember[]) => {
|
a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => {
|
||||||
expect(data.length).toEqual(2);
|
expect(data.length).toEqual(2);
|
||||||
expectObservable(data[0].membership$).toBe("a", {
|
expectObservable(data[0].membership$).toBe("a", {
|
||||||
a: bobMembership,
|
a: bobMembership,
|
||||||
@@ -371,7 +380,7 @@ describe("Publication edge case", () => {
|
|||||||
// The real connection should be from transportA as per the membership
|
// The real connection should be from transportA as per the membership
|
||||||
a: connectionA,
|
a: connectionA,
|
||||||
});
|
});
|
||||||
expectObservable(data[0].participant$).toBe("a", {
|
expectObservable(data[0].participant.value$).toBe("a", {
|
||||||
// No participant as Bob is not publishing on his membership transport
|
// No participant as Bob is not publishing on his membership transport
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||||
type LocalParticipant as LocalLivekitParticipant,
|
|
||||||
type RemoteParticipant as RemoteLivekitParticipant,
|
|
||||||
} from "livekit-client";
|
|
||||||
import {
|
import {
|
||||||
type LivekitTransport,
|
type LivekitTransport,
|
||||||
type CallMembership,
|
type CallMembership,
|
||||||
@@ -24,22 +21,44 @@ import { generateItemsWithEpoch } from "../../../utils/observable";
|
|||||||
|
|
||||||
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
|
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
|
||||||
|
|
||||||
/**
|
interface LocalTaggedParticipant {
|
||||||
* Represents a Matrix call member and their associated LiveKit participation.
|
type: "local";
|
||||||
* `livekitParticipant` can be undefined if the member is not yet connected to the livekit room
|
value$: Behavior<LocalParticipant | null>;
|
||||||
* or if it has no livekit transport at all.
|
}
|
||||||
*/
|
interface RemoteTaggedParticipant {
|
||||||
export interface MatrixLivekitMember {
|
type: "remote";
|
||||||
|
value$: Behavior<RemoteParticipant | null>;
|
||||||
|
}
|
||||||
|
export type TaggedParticipant =
|
||||||
|
| LocalTaggedParticipant
|
||||||
|
| RemoteTaggedParticipant;
|
||||||
|
|
||||||
|
interface MatrixLivekitMember {
|
||||||
membership$: Behavior<CallMembership>;
|
membership$: Behavior<CallMembership>;
|
||||||
participant$: Behavior<
|
|
||||||
LocalLivekitParticipant | RemoteLivekitParticipant | null
|
|
||||||
>;
|
|
||||||
connection$: Behavior<Connection | null>;
|
connection$: Behavior<Connection | null>;
|
||||||
// participantId: string; We do not want a participantId here since it will be generated by the jwt
|
// participantId: string; We do not want a participantId here since it will be generated by the jwt
|
||||||
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
|
// TODO decide if we can also drop the userId. Its in the matrix membership anyways.
|
||||||
userId: string;
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the local Matrix call member and their associated LiveKit participation.
|
||||||
|
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
|
||||||
|
* or if it has no livekit transport at all.
|
||||||
|
*/
|
||||||
|
export interface LocalMatrixLivekitMember extends MatrixLivekitMember {
|
||||||
|
participant: LocalTaggedParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a remote Matrix call member and their associated LiveKit participation.
|
||||||
|
* `livekitParticipant` can be null if the member is not yet connected to the livekit room
|
||||||
|
* or if it has no livekit transport at all.
|
||||||
|
*/
|
||||||
|
export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
|
||||||
|
participant: RemoteTaggedParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
scope: ObservableScope;
|
scope: ObservableScope;
|
||||||
membershipsWithTransport$: Behavior<
|
membershipsWithTransport$: Behavior<
|
||||||
@@ -61,7 +80,7 @@ export function createMatrixLivekitMembers$({
|
|||||||
scope,
|
scope,
|
||||||
membershipsWithTransport$,
|
membershipsWithTransport$,
|
||||||
connectionManager,
|
connectionManager,
|
||||||
}: Props): Behavior<Epoch<MatrixLivekitMember[]>> {
|
}: Props): Behavior<Epoch<RemoteMatrixLivekitMember[]>> {
|
||||||
/**
|
/**
|
||||||
* Stream of all the call members and their associated livekit data (if available).
|
* Stream of all the call members and their associated livekit data (if available).
|
||||||
*/
|
*/
|
||||||
@@ -91,7 +110,7 @@ export function createMatrixLivekitMembers$({
|
|||||||
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
|
const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`;
|
||||||
|
|
||||||
const participants = transport
|
const participants = transport
|
||||||
? managerData.getParticipantForTransport(transport)
|
? managerData.getParticipantsForTransport(transport)
|
||||||
: [];
|
: [];
|
||||||
const participant =
|
const participant =
|
||||||
participants.find((p) => p.identity == participantId) ?? null;
|
participants.find((p) => p.identity == participantId) ?? null;
|
||||||
@@ -108,14 +127,16 @@ export function createMatrixLivekitMembers$({
|
|||||||
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
// Each update where the key of the generator array do not change will result in updates to the `data$` observable in the factory.
|
||||||
(scope, data$, participantId, userId) => {
|
(scope, data$, participantId, userId) => {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Updating data$ for participantId: ${participantId}, userId: ${userId}`,
|
`Generating member for participantId: ${participantId}, userId: ${userId}`,
|
||||||
);
|
);
|
||||||
|
const { participant$, ...rest } = scope.splitBehavior(data$);
|
||||||
// will only get called once per `participantId, userId` pair.
|
// will only get called once per `participantId, userId` pair.
|
||||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||||
return {
|
return {
|
||||||
participantId,
|
participantId,
|
||||||
userId,
|
userId,
|
||||||
...scope.splitBehavior(data$),
|
participant: { type: "remote" as const, value$: participant$ },
|
||||||
|
...rest,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ import { type ProcessorState } from "../../../livekit/TrackProcessorContext.tsx"
|
|||||||
import {
|
import {
|
||||||
areLivekitTransportsEqual,
|
areLivekitTransportsEqual,
|
||||||
createMatrixLivekitMembers$,
|
createMatrixLivekitMembers$,
|
||||||
type MatrixLivekitMember,
|
type RemoteMatrixLivekitMember,
|
||||||
} from "./MatrixLivekitMembers.ts";
|
} from "./MatrixLivekitMembers.ts";
|
||||||
import { createConnectionManager$ } from "./ConnectionManager.ts";
|
import { createConnectionManager$ } from "./ConnectionManager.ts";
|
||||||
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
|
import { membershipsAndTransports$ } from "../../SessionBehaviors.ts";
|
||||||
|
import { testJWTToken } from "../../../utils/test-fixtures.ts";
|
||||||
|
|
||||||
// Test the integration of ConnectionManager and MatrixLivekitMerger
|
// Test the integration of ConnectionManager and MatrixLivekitMerger
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ beforeEach(() => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
url: `wss://${domain}/livekit/sfu`,
|
url: `wss://${domain}/livekit/sfu`,
|
||||||
jwt: "ATOKEN",
|
jwt: testJWTToken,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -124,15 +125,15 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const matrixLivekitItems$ = createMatrixLivekitMembers$({
|
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||||
scope: testScope,
|
scope: testScope,
|
||||||
membershipsWithTransport$:
|
membershipsWithTransport$:
|
||||||
membershipsAndTransports.membershipsWithTransport$,
|
membershipsAndTransports.membershipsWithTransport$,
|
||||||
connectionManager,
|
connectionManager,
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(matrixLivekitItems$).toBe(vMarble, {
|
expectObservable(matrixLivekitMembers$).toBe(vMarble, {
|
||||||
a: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
|
a: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||||
const items = e.value;
|
const items = e.value;
|
||||||
expect(items.length).toBe(1);
|
expect(items.length).toBe(1);
|
||||||
const item = items[0]!;
|
const item = items[0]!;
|
||||||
@@ -147,12 +148,12 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
b: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
|
b: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||||
const items = e.value;
|
const items = e.value;
|
||||||
expect(items.length).toBe(2);
|
expect(items.length).toBe(2);
|
||||||
|
|
||||||
@@ -161,7 +162,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
expectObservable(item.membership$).toBe("a", {
|
expectObservable(item.membership$).toBe("a", {
|
||||||
a: bobMembership,
|
a: bobMembership,
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -172,7 +173,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
expectObservable(item.membership$).toBe("a", {
|
expectObservable(item.membership$).toBe("a", {
|
||||||
a: carlMembership,
|
a: carlMembership,
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
expectObservable(item.connection$).toBe("a", {
|
expectObservable(item.connection$).toBe("a", {
|
||||||
@@ -189,7 +190,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
c: expect.toSatisfy((e: Epoch<MatrixLivekitMember[]>) => {
|
c: expect.toSatisfy((e: Epoch<RemoteMatrixLivekitMember[]>) => {
|
||||||
const items = e.value;
|
const items = e.value;
|
||||||
expect(items.length).toBe(3);
|
expect(items.length).toBe(3);
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
|||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expectObservable(item.participant$).toBe("a", {
|
expectObservable(item.participant.value$).toBe("a", {
|
||||||
a: null,
|
a: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { constant } from "./Behavior.ts";
|
|||||||
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
||||||
import { ElementWidgetActions, widget } from "../widget.ts";
|
import { ElementWidgetActions, widget } from "../widget.ts";
|
||||||
import { E2eeType } from "../e2ee/e2eeType.ts";
|
import { E2eeType } from "../e2ee/e2eeType.ts";
|
||||||
|
import { MatrixRTCMode } from "../settings/settings.ts";
|
||||||
|
|
||||||
vi.mock("@livekit/components-core", { spy: true });
|
vi.mock("@livekit/components-core", { spy: true });
|
||||||
|
|
||||||
@@ -34,36 +35,43 @@ vi.mock("../widget", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it("expect leave when ElementWidgetActions.HangupCall is called", async () => {
|
it.each([
|
||||||
const pr = Promise.withResolvers<string>();
|
[MatrixRTCMode.Legacy],
|
||||||
withCallViewModel(
|
[MatrixRTCMode.Compatibil],
|
||||||
{
|
[MatrixRTCMode.Matrix_2_0],
|
||||||
remoteParticipants$: constant([aliceParticipant]),
|
])(
|
||||||
rtcMembers$: constant([localRtcMember]),
|
"expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
|
||||||
},
|
async (mode) => {
|
||||||
(vm: CallViewModel) => {
|
const pr = Promise.withResolvers<string>();
|
||||||
vm.leave$.subscribe((s: string) => {
|
withCallViewModel(mode)(
|
||||||
pr.resolve(s);
|
{
|
||||||
});
|
remoteParticipants$: constant([aliceParticipant]),
|
||||||
|
rtcMembers$: constant([localRtcMember]),
|
||||||
|
},
|
||||||
|
(vm: CallViewModel) => {
|
||||||
|
vm.leave$.subscribe((s: string) => {
|
||||||
|
pr.resolve(s);
|
||||||
|
});
|
||||||
|
|
||||||
widget!.lazyActions!.emit(
|
widget!.lazyActions!.emit(
|
||||||
ElementWidgetActions.HangupCall,
|
ElementWidgetActions.HangupCall,
|
||||||
new CustomEvent(ElementWidgetActions.HangupCall, {
|
new CustomEvent(ElementWidgetActions.HangupCall, {
|
||||||
detail: {
|
detail: {
|
||||||
action: "im.vector.hangup",
|
action: "im.vector.hangup",
|
||||||
api: "toWidget",
|
api: "toWidget",
|
||||||
data: {},
|
data: {},
|
||||||
requestId: "widgetapi-1761237395918",
|
requestId: "widgetapi-1761237395918",
|
||||||
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
|
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const source = await pr.promise;
|
const source = await pr.promise;
|
||||||
expect(source).toBe("user");
|
expect(source).toBe("user");
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
createLocalMedia,
|
createLocalMedia,
|
||||||
createRemoteMedia,
|
createRemoteMedia,
|
||||||
withTestScheduler,
|
withTestScheduler,
|
||||||
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { getValue } from "../utils/observable";
|
import { getValue } from "../utils/observable";
|
||||||
import { constant } from "./Behavior";
|
import { constant } from "./Behavior";
|
||||||
@@ -44,7 +45,11 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
|||||||
|
|
||||||
test("control a participant's volume", () => {
|
test("control a participant's volume", () => {
|
||||||
const setVolumeSpy = vi.fn();
|
const setVolumeSpy = vi.fn();
|
||||||
const vm = createRemoteMedia(rtcMembership, {}, { setVolume: setVolumeSpy });
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||||
|
);
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-ab---c---d|", {
|
schedule("-ab---c---d|", {
|
||||||
a() {
|
a() {
|
||||||
@@ -88,7 +93,7 @@ test("control a participant's volume", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("toggle fit/contain for a participant's video", () => {
|
test("toggle fit/contain for a participant's video", () => {
|
||||||
const vm = createRemoteMedia(rtcMembership, {}, {});
|
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||||
withTestScheduler(({ expectObservable, schedule }) => {
|
withTestScheduler(({ expectObservable, schedule }) => {
|
||||||
schedule("-ab|", {
|
schedule("-ab|", {
|
||||||
a: () => vm.toggleFitContain(),
|
a: () => vm.toggleFitContain(),
|
||||||
@@ -199,3 +204,35 @@ test("switch cameras", async () => {
|
|||||||
});
|
});
|
||||||
expect(deviceId).toBe("front camera");
|
expect(deviceId).toBe("front camera");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("remote media is in waiting state when participant has not yet connected", () => {
|
||||||
|
const vm = createRemoteMedia(rtcMembership, {}, null); // null participant
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when participant is connected", () => {
|
||||||
|
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when participant is connected with no publications", () => {
|
||||||
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({
|
||||||
|
getTrackPublication: () => undefined,
|
||||||
|
getTrackPublications: () => [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
|
||||||
|
const vm = createRemoteMedia(
|
||||||
|
rtcMembership,
|
||||||
|
{},
|
||||||
|
mockRemoteParticipant({}),
|
||||||
|
undefined, // No room (no advertised transport)
|
||||||
|
);
|
||||||
|
expect(vm.waitingForMedia$.value).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AudioSource,
|
type AudioSource,
|
||||||
type TrackReferenceOrPlaceholder,
|
|
||||||
type VideoSource,
|
type VideoSource,
|
||||||
|
type TrackReference,
|
||||||
observeParticipantEvents,
|
observeParticipantEvents,
|
||||||
observeParticipantMedia,
|
observeParticipantMedia,
|
||||||
roomEventSelector,
|
roomEventSelector,
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
type Observable,
|
type Observable,
|
||||||
Subject,
|
Subject,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
distinctUntilKeyChanged,
|
|
||||||
filter,
|
filter,
|
||||||
fromEvent,
|
fromEvent,
|
||||||
interval,
|
interval,
|
||||||
@@ -60,14 +59,11 @@ import { type ObservableScope } from "./ObservableScope";
|
|||||||
export function observeTrackReference$(
|
export function observeTrackReference$(
|
||||||
participant: Participant,
|
participant: Participant,
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Observable<TrackReferenceOrPlaceholder> {
|
): Observable<TrackReference | undefined> {
|
||||||
return observeParticipantMedia(participant).pipe(
|
return observeParticipantMedia(participant).pipe(
|
||||||
map(() => ({
|
map(() => participant.getTrackPublication(source)),
|
||||||
participant: participant,
|
distinctUntilChanged(),
|
||||||
publication: participant.getTrackPublication(source),
|
map((publication) => publication && { participant, publication, source }),
|
||||||
source,
|
|
||||||
})),
|
|
||||||
distinctUntilKeyChanged("publication"),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +222,7 @@ abstract class BaseMediaViewModel {
|
|||||||
/**
|
/**
|
||||||
* The LiveKit video track for this media.
|
* The LiveKit video track for this media.
|
||||||
*/
|
*/
|
||||||
public readonly video$: Behavior<TrackReferenceOrPlaceholder | null>;
|
public readonly video$: Behavior<TrackReference | undefined>;
|
||||||
/**
|
/**
|
||||||
* Whether there should be a warning that this media is unencrypted.
|
* Whether there should be a warning that this media is unencrypted.
|
||||||
*/
|
*/
|
||||||
@@ -241,10 +237,12 @@ abstract class BaseMediaViewModel {
|
|||||||
|
|
||||||
private observeTrackReference$(
|
private observeTrackReference$(
|
||||||
source: Track.Source,
|
source: Track.Source,
|
||||||
): Behavior<TrackReferenceOrPlaceholder | null> {
|
): Behavior<TrackReference | undefined> {
|
||||||
return this.scope.behavior(
|
return this.scope.behavior(
|
||||||
this.participant$.pipe(
|
this.participant$.pipe(
|
||||||
switchMap((p) => (!p ? of(null) : observeTrackReference$(p, source))),
|
switchMap((p) =>
|
||||||
|
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,7 +266,7 @@ abstract class BaseMediaViewModel {
|
|||||||
encryptionSystem: EncryptionSystem,
|
encryptionSystem: EncryptionSystem,
|
||||||
audioSource: AudioSource,
|
audioSource: AudioSource,
|
||||||
videoSource: VideoSource,
|
videoSource: VideoSource,
|
||||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
public readonly focusUrl$: Behavior<string | undefined>,
|
public readonly focusUrl$: Behavior<string | undefined>,
|
||||||
public readonly displayName$: Behavior<string>,
|
public readonly displayName$: Behavior<string>,
|
||||||
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||||
@@ -281,8 +279,8 @@ abstract class BaseMediaViewModel {
|
|||||||
[audio$, this.video$],
|
[audio$, this.video$],
|
||||||
(a, v) =>
|
(a, v) =>
|
||||||
encryptionSystem.kind !== E2eeType.NONE &&
|
encryptionSystem.kind !== E2eeType.NONE &&
|
||||||
(a?.publication?.isEncrypted === false ||
|
(a?.publication.isEncrypted === false ||
|
||||||
v?.publication?.isEncrypted === false),
|
v?.publication.isEncrypted === false),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -471,7 +469,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||||
this.video$.pipe(
|
this.video$.pipe(
|
||||||
switchMap((v) => {
|
switchMap((v) => {
|
||||||
const track = v?.publication?.track;
|
const track = v?.publication.track;
|
||||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||||
return merge(
|
return merge(
|
||||||
// Watch for track restarts because they indicate a camera switch.
|
// Watch for track restarts because they indicate a camera switch.
|
||||||
@@ -596,6 +594,21 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
|||||||
* A remote participant's user media.
|
* A remote participant's user media.
|
||||||
*/
|
*/
|
||||||
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
|
||||||
|
/**
|
||||||
|
* Whether we are waiting for this user's LiveKit participant to exist. This
|
||||||
|
* could be because either we or the remote party are still connecting.
|
||||||
|
*/
|
||||||
|
public readonly waitingForMedia$ = this.scope.behavior<boolean>(
|
||||||
|
combineLatest(
|
||||||
|
[this.livekitRoom$, this.participant$],
|
||||||
|
(livekitRoom, participant) =>
|
||||||
|
// If livekitRoom is undefined, the user is not attempting to publish on
|
||||||
|
// any transport and so we shouldn't expect a participant. (They might
|
||||||
|
// be a subscribe-only bot for example.)
|
||||||
|
livekitRoom !== undefined && participant === null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// This private field is used to override the value from the superclass
|
// This private field is used to override the value from the superclass
|
||||||
private __speaking$: Behavior<boolean>;
|
private __speaking$: Behavior<boolean>;
|
||||||
public get speaking$(): Behavior<boolean> {
|
public get speaking$(): Behavior<boolean> {
|
||||||
|
|||||||
212
src/state/MuteStates.test.ts
Normal file
212
src/state/MuteStates.test.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations 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, describe, expect, test, vi } from "vitest";
|
||||||
|
import { BehaviorSubject } from "rxjs";
|
||||||
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
|
import { MuteStates, MuteState } from "./MuteStates";
|
||||||
|
import {
|
||||||
|
type AudioOutputDeviceLabel,
|
||||||
|
type DeviceLabel,
|
||||||
|
type MediaDevice,
|
||||||
|
type SelectedAudioOutputDevice,
|
||||||
|
type SelectedDevice,
|
||||||
|
} from "./MediaDevices";
|
||||||
|
import { constant } from "./Behavior";
|
||||||
|
import { ObservableScope } from "./ObservableScope";
|
||||||
|
import { flushPromises, mockMediaDevices } from "../utils/test";
|
||||||
|
|
||||||
|
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||||
|
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||||
|
|
||||||
|
let testScope: ObservableScope;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testScope = new ObservableScope();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testScope.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MuteState", () => {
|
||||||
|
test("should automatically mute if force mute is set", async () => {
|
||||||
|
const forceMute$ = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
|
const deviceStub = {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["fbac11", { type: "name", name: "HD Camera" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$: constant({ id: "fbac11" }),
|
||||||
|
select(): void {},
|
||||||
|
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
|
||||||
|
|
||||||
|
const muteState = new MuteState(
|
||||||
|
testScope,
|
||||||
|
deviceStub,
|
||||||
|
constant(true),
|
||||||
|
true,
|
||||||
|
forceMute$,
|
||||||
|
);
|
||||||
|
let lastEnabled: boolean = false;
|
||||||
|
muteState.enabled$.subscribe((enabled) => {
|
||||||
|
lastEnabled = enabled;
|
||||||
|
});
|
||||||
|
let setEnabled: ((enabled: boolean) => void) | null = null;
|
||||||
|
muteState.setEnabled$.subscribe((setter) => {
|
||||||
|
setEnabled = setter;
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
setEnabled!(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(lastEnabled).toBe(true);
|
||||||
|
|
||||||
|
// Now force mute
|
||||||
|
forceMute$.next(true);
|
||||||
|
await flushPromises();
|
||||||
|
// Should automatically mute
|
||||||
|
expect(lastEnabled).toBe(false);
|
||||||
|
|
||||||
|
// Try to unmute can not work
|
||||||
|
expect(setEnabled).toBeNull();
|
||||||
|
|
||||||
|
// Disable force mute
|
||||||
|
forceMute$.next(false);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// TODO I'd expect it to go back to previous state (enabled)
|
||||||
|
// but actually it goes back to the initial state from construction (disabled)
|
||||||
|
// Should go back to previous state (enabled)
|
||||||
|
// Skip for now
|
||||||
|
// expect(lastEnabled).toBe(true);
|
||||||
|
|
||||||
|
// But yet it can be unmuted now
|
||||||
|
expect(setEnabled).not.toBeNull();
|
||||||
|
|
||||||
|
setEnabled!(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(lastEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MuteStates", () => {
|
||||||
|
function aAudioOutputDevices(): MediaDevice<
|
||||||
|
AudioOutputDeviceLabel,
|
||||||
|
SelectedAudioOutputDevice
|
||||||
|
> {
|
||||||
|
const selected$ = new BehaviorSubject<
|
||||||
|
SelectedAudioOutputDevice | undefined
|
||||||
|
>({
|
||||||
|
id: "default",
|
||||||
|
virtualEarpiece: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, AudioOutputDeviceLabel>([
|
||||||
|
["default", { type: "speaker" }],
|
||||||
|
["0000", { type: "speaker" }],
|
||||||
|
["1111", { type: "earpiece" }],
|
||||||
|
["222", { type: "name", name: "Bluetooth Speaker" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$,
|
||||||
|
select(id: string): void {
|
||||||
|
if (!this.available$.getValue().has(id)) {
|
||||||
|
logger.warn(`Attempted to select unknown device id: ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected$.next({
|
||||||
|
id,
|
||||||
|
/** For test purposes we ignore this */
|
||||||
|
virtualEarpiece: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aVideoInput(): MediaDevice<DeviceLabel, SelectedDevice> {
|
||||||
|
const selected$ = new BehaviorSubject<SelectedDevice | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
available$: constant(
|
||||||
|
new Map<string, DeviceLabel>([
|
||||||
|
["0000", { type: "name", name: "HD Camera" }],
|
||||||
|
["1111", { type: "name", name: "WebCam Pro" }],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
selected$,
|
||||||
|
select(id: string): void {
|
||||||
|
if (!this.available$.getValue().has(id)) {
|
||||||
|
logger.warn(`Attempted to select unknown device id: ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selected$.next({ id });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("should mute camera when in earpiece mode", async () => {
|
||||||
|
const audioOutputDevice = aAudioOutputDevices();
|
||||||
|
|
||||||
|
const mediaDevices = mockMediaDevices({
|
||||||
|
audioOutput: audioOutputDevice,
|
||||||
|
videoInput: aVideoInput(),
|
||||||
|
// other devices are not relevant for this test
|
||||||
|
});
|
||||||
|
const muteStates = new MuteStates(
|
||||||
|
testScope,
|
||||||
|
mediaDevices,
|
||||||
|
// consider joined
|
||||||
|
constant(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
let latestSyncedState: boolean | null = null;
|
||||||
|
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
|
||||||
|
logger.info(`Video mute state set to: ${enabled}`);
|
||||||
|
latestSyncedState = enabled;
|
||||||
|
return Promise.resolve(enabled);
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastVideoEnabled: boolean = false;
|
||||||
|
muteStates.video.enabled$.subscribe((enabled) => {
|
||||||
|
lastVideoEnabled = enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(muteStates.video.setEnabled$.value).toBeDefined();
|
||||||
|
muteStates.video.setEnabled$.value?.(true);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(lastVideoEnabled).toBe(true);
|
||||||
|
|
||||||
|
// Select earpiece audio output
|
||||||
|
audioOutputDevice.select("1111");
|
||||||
|
await flushPromises();
|
||||||
|
// Video should be automatically muted
|
||||||
|
expect(lastVideoEnabled).toBe(false);
|
||||||
|
expect(latestSyncedState).toBe(false);
|
||||||
|
|
||||||
|
// Try to switch to speaker
|
||||||
|
audioOutputDevice.select("0000");
|
||||||
|
await flushPromises();
|
||||||
|
// TODO I'd expect it to go back to previous state (enabled)??
|
||||||
|
// But maybe not? If you move the phone away from your ear you may not want it
|
||||||
|
// to automatically enable video?
|
||||||
|
expect(lastVideoEnabled).toBe(false);
|
||||||
|
|
||||||
|
// But yet it can be unmuted now
|
||||||
|
expect(muteStates.video.setEnabled$.value).toBeDefined();
|
||||||
|
muteStates.video.setEnabled$.value?.(true);
|
||||||
|
await flushPromises();
|
||||||
|
expect(lastVideoEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ import { ElementWidgetActions, widget } from "../widget";
|
|||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams } from "../UrlParams";
|
||||||
import { type ObservableScope } from "./ObservableScope";
|
import { type ObservableScope } from "./ObservableScope";
|
||||||
import { type Behavior } from "./Behavior";
|
import { type Behavior, constant } from "./Behavior";
|
||||||
|
|
||||||
interface MuteStateData {
|
interface MuteStateData {
|
||||||
enabled$: Observable<boolean>;
|
enabled$: Observable<boolean>;
|
||||||
@@ -38,31 +38,58 @@ interface MuteStateData {
|
|||||||
export type Handler = (desired: boolean) => Promise<boolean>;
|
export type Handler = (desired: boolean) => Promise<boolean>;
|
||||||
const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
|
const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
|
||||||
|
|
||||||
class MuteState<Label, Selected> {
|
/**
|
||||||
|
* Internal class - exported only for testing purposes.
|
||||||
|
* Do not use directly outside of tests.
|
||||||
|
*/
|
||||||
|
export class MuteState<Label, Selected> {
|
||||||
|
// TODO: rewrite this to explain behavior, it is not understandable, and cannot add logging
|
||||||
private readonly enabledByDefault$ =
|
private readonly enabledByDefault$ =
|
||||||
this.enabledByConfig && !getUrlParams().skipLobby
|
this.enabledByConfig && !getUrlParams().skipLobby
|
||||||
? this.joined$.pipe(map((isJoined) => !isJoined))
|
? this.joined$.pipe(map((isJoined) => !isJoined))
|
||||||
: of(false);
|
: of(false);
|
||||||
|
|
||||||
private readonly handler$ = new BehaviorSubject(defaultHandler);
|
private readonly handler$ = new BehaviorSubject(defaultHandler);
|
||||||
|
|
||||||
public setHandler(handler: Handler): void {
|
public setHandler(handler: Handler): void {
|
||||||
if (this.handler$.value !== defaultHandler)
|
if (this.handler$.value !== defaultHandler)
|
||||||
throw new Error("Multiple mute state handlers are not supported");
|
throw new Error("Multiple mute state handlers are not supported");
|
||||||
this.handler$.next(handler);
|
this.handler$.next(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
public unsetHandler(): void {
|
public unsetHandler(): void {
|
||||||
this.handler$.next(defaultHandler);
|
this.handler$.next(defaultHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly canControlDevices$ = combineLatest([
|
||||||
|
this.device.available$,
|
||||||
|
this.forceMute$,
|
||||||
|
]).pipe(
|
||||||
|
map(([available, forceMute]) => {
|
||||||
|
return !forceMute && available.size > 0;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly data$ = this.scope.behavior<MuteStateData>(
|
private readonly data$ = this.scope.behavior<MuteStateData>(
|
||||||
this.device.available$.pipe(
|
this.canControlDevices$.pipe(
|
||||||
map((available) => available.size > 0),
|
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.enabledByDefault$,
|
this.enabledByDefault$,
|
||||||
(devicesConnected, enabledByDefault) => {
|
(canControlDevices, enabledByDefault) => {
|
||||||
if (!devicesConnected)
|
logger.info(
|
||||||
|
`MuteState: canControlDevices: ${canControlDevices}, enabled by default: ${enabledByDefault}`,
|
||||||
|
);
|
||||||
|
if (!canControlDevices) {
|
||||||
|
logger.info(
|
||||||
|
`MuteState: devices connected: ${canControlDevices}, disabling`,
|
||||||
|
);
|
||||||
|
// We need to sync the mute state with the handler
|
||||||
|
// to ensure nothing is beeing published.
|
||||||
|
this.handler$.value(false).catch((err) => {
|
||||||
|
logger.error("MuteState-disable: handler error", err);
|
||||||
|
});
|
||||||
return { enabled$: of(false), set: null, toggle: null };
|
return { enabled$: of(false), set: null, toggle: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Assume the default value only once devices are actually connected
|
// Assume the default value only once devices are actually connected
|
||||||
let enabled = enabledByDefault;
|
let enabled = enabledByDefault;
|
||||||
@@ -135,21 +162,45 @@ class MuteState<Label, Selected> {
|
|||||||
private readonly device: MediaDevice<Label, Selected>,
|
private readonly device: MediaDevice<Label, Selected>,
|
||||||
private readonly joined$: Observable<boolean>,
|
private readonly joined$: Observable<boolean>,
|
||||||
private readonly enabledByConfig: boolean,
|
private readonly enabledByConfig: boolean,
|
||||||
|
/**
|
||||||
|
* An optional observable which, when it emits `true`, will force the mute.
|
||||||
|
* Used for video to stop camera when earpiece mode is on.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly forceMute$: Observable<boolean>,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MuteStates {
|
export class MuteStates {
|
||||||
|
/**
|
||||||
|
* True if the selected audio output device is an earpiece.
|
||||||
|
* Used to force-disable video when on earpiece.
|
||||||
|
*/
|
||||||
|
private readonly isEarpiece$ = combineLatest(
|
||||||
|
this.mediaDevices.audioOutput.available$,
|
||||||
|
this.mediaDevices.audioOutput.selected$,
|
||||||
|
).pipe(
|
||||||
|
map(([available, selected]) => {
|
||||||
|
if (!selected?.id) return false;
|
||||||
|
const device = available.get(selected.id);
|
||||||
|
logger.info(`MuteStates: selected audio output device:`, device);
|
||||||
|
return device?.type === "earpiece";
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
public readonly audio = new MuteState(
|
public readonly audio = new MuteState(
|
||||||
this.scope,
|
this.scope,
|
||||||
this.mediaDevices.audioInput,
|
this.mediaDevices.audioInput,
|
||||||
this.joined$,
|
this.joined$,
|
||||||
Config.get().media_devices.enable_audio,
|
Config.get().media_devices.enable_audio,
|
||||||
|
constant(false),
|
||||||
);
|
);
|
||||||
public readonly video = new MuteState(
|
public readonly video = new MuteState(
|
||||||
this.scope,
|
this.scope,
|
||||||
this.mediaDevices.videoInput,
|
this.mediaDevices.videoInput,
|
||||||
this.joined$,
|
this.joined$,
|
||||||
Config.get().media_devices.enable_video,
|
Config.get().media_devices.enable_video,
|
||||||
|
this.isEarpiece$,
|
||||||
);
|
);
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { BehaviorSubject, combineLatest, Subject } from "rxjs";
|
import { BehaviorSubject, combineLatest, Subject } from "rxjs";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { sleep } from "matrix-js-sdk/lib/utils";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Epoch,
|
Epoch,
|
||||||
@@ -102,3 +103,137 @@ describe("Epoch", () => {
|
|||||||
s$.complete();
|
s$.complete();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Reconcile", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wait clean up before processing next", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const behavior$ = new BehaviorSubject<number>(0);
|
||||||
|
|
||||||
|
const setup = vi.fn().mockImplementation(async () => await sleep(100));
|
||||||
|
const cleanup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await sleep(100));
|
||||||
|
scope.reconcile(behavior$, async (value) => {
|
||||||
|
await setup();
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await cleanup(value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Let the initial setup process
|
||||||
|
await vi.advanceTimersByTimeAsync(120);
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
// Send next value
|
||||||
|
behavior$.next(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
// Should not have started setup for 1 yet
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
|
// Let cleanup finish
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
// Now setup for 1 should have started
|
||||||
|
expect(setup).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should skip intermediates values that are not setup", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const behavior$ = new BehaviorSubject<number>(0);
|
||||||
|
|
||||||
|
const setup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await sleep(100));
|
||||||
|
|
||||||
|
const cleanupLock = Promise.withResolvers();
|
||||||
|
const cleanup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await cleanupLock.promise);
|
||||||
|
|
||||||
|
scope.reconcile(behavior$, async (value) => {
|
||||||
|
await setup(value);
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await cleanup(value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Let the initial setup process (0)
|
||||||
|
await vi.advanceTimersByTimeAsync(120);
|
||||||
|
|
||||||
|
// Send 4 next values quickly
|
||||||
|
behavior$.next(1);
|
||||||
|
behavior$.next(2);
|
||||||
|
behavior$.next(3);
|
||||||
|
behavior$.next(4);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(3000);
|
||||||
|
// should have only called cleanup for 0
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledWith(0);
|
||||||
|
// Let cleanup finish
|
||||||
|
cleanupLock.resolve(undefined);
|
||||||
|
await vi.advanceTimersByTimeAsync(120);
|
||||||
|
|
||||||
|
// Now setup for 4 should have started, skipping 1,2,3
|
||||||
|
expect(setup).toHaveBeenCalledTimes(2);
|
||||||
|
expect(setup).toHaveBeenCalledWith(4);
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(1);
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(2);
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wait for setup to complete before starting cleanup", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const scope = new ObservableScope();
|
||||||
|
const behavior$ = new BehaviorSubject<number>(0);
|
||||||
|
|
||||||
|
const setup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await sleep(3000));
|
||||||
|
|
||||||
|
const cleanupLock = Promise.withResolvers();
|
||||||
|
const cleanup = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (n: number) => await cleanupLock.promise);
|
||||||
|
|
||||||
|
scope.reconcile(behavior$, async (value) => {
|
||||||
|
await setup(value);
|
||||||
|
return async (): Promise<void> => {
|
||||||
|
await cleanup(value);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
// Setup for 0 should be in progress
|
||||||
|
expect(setup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
behavior$.next(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
|
||||||
|
// Should not have started setup for 1 yet
|
||||||
|
expect(setup).not.toHaveBeenCalledWith(1);
|
||||||
|
// Should not have called cleanup yet, because the setup for 0 is not done
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
// Let setup for 0 finish
|
||||||
|
await vi.advanceTimersByTimeAsync(2500 + 100);
|
||||||
|
// Now cleanup for 0 should have started
|
||||||
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cleanup).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
|
cleanupLock.resolve(undefined);
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
// Now setup for 1 should have started
|
||||||
|
expect(setup).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -123,8 +123,22 @@ export class ObservableScope {
|
|||||||
callback: (value: T) => Promise<(() => Promise<void>) | void>,
|
callback: (value: T) => Promise<(() => Promise<void>) | void>,
|
||||||
): void {
|
): void {
|
||||||
let latestValue: T | typeof nothing = nothing;
|
let latestValue: T | typeof nothing = nothing;
|
||||||
let reconciledValue: T | typeof nothing = nothing;
|
let reconcilePromise: Promise<void> | undefined = undefined;
|
||||||
let cleanUp: (() => Promise<void>) | void = undefined;
|
let cleanUp: (() => Promise<void>) | void = undefined;
|
||||||
|
let prevVal: T | typeof nothing = nothing;
|
||||||
|
|
||||||
|
// While this loop runs it will process the latest from `value$` until it caught up with the updates.
|
||||||
|
// It might skip updates from `value$` and only process the newest value after callback has resolved.
|
||||||
|
const reconcileLoop = async (): Promise<void> => {
|
||||||
|
while (latestValue !== prevVal) {
|
||||||
|
await cleanUp?.(); // Call the previous value's clean-up handler
|
||||||
|
prevVal = latestValue;
|
||||||
|
|
||||||
|
if (latestValue !== nothing) cleanUp = await callback(latestValue); // Sync current value...
|
||||||
|
// `latestValue` might have gotten updated during the `await callback`. That is why we loop here
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
value$
|
value$
|
||||||
.pipe(
|
.pipe(
|
||||||
catchError(() => EMPTY), // Ignore errors
|
catchError(() => EMPTY), // Ignore errors
|
||||||
@@ -132,23 +146,15 @@ export class ObservableScope {
|
|||||||
endWith(nothing), // Clean up when the scope ends
|
endWith(nothing), // Clean up when the scope ends
|
||||||
)
|
)
|
||||||
.subscribe((value) => {
|
.subscribe((value) => {
|
||||||
void (async (): Promise<void> => {
|
// Always track the latest value! The `reconcileLoop` will run until it "processed" the "last" `latestValue`.
|
||||||
if (latestValue === nothing) {
|
latestValue = value;
|
||||||
latestValue = value;
|
// There's already an instance of the below 'reconcileLoop' loop running
|
||||||
while (latestValue !== reconciledValue) {
|
// concurrently. So lets let the loop handle it. NEVER instanciate two `reconcileLoop`s.
|
||||||
await cleanUp?.(); // Call the previous value's clean-up handler
|
if (reconcilePromise) return;
|
||||||
reconciledValue = latestValue;
|
|
||||||
if (latestValue !== nothing)
|
reconcilePromise = reconcileLoop().finally(() => {
|
||||||
cleanUp = await callback(latestValue); // Sync current value
|
reconcilePromise = undefined;
|
||||||
}
|
});
|
||||||
// Reset to signal that reconciliation is done for now
|
|
||||||
latestValue = nothing;
|
|
||||||
} else {
|
|
||||||
// There's already an instance of the above 'while' loop running
|
|
||||||
// concurrently. Just update the latest value and let it be handled.
|
|
||||||
latestValue = value;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { ReactionOption } from "../reactions";
|
|||||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||||
import { generateItems } from "../utils/observable.ts";
|
import { generateItems } from "../utils/observable.ts";
|
||||||
import { ScreenShare } from "./ScreenShare.ts";
|
import { ScreenShare } from "./ScreenShare.ts";
|
||||||
|
import { type TaggedParticipant } from "./CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||||
@@ -68,40 +69,46 @@ enum SortingBin {
|
|||||||
* for inclusion in the call layout and tracks associated screen shares.
|
* for inclusion in the call layout and tracks associated screen shares.
|
||||||
*/
|
*/
|
||||||
export class UserMedia {
|
export class UserMedia {
|
||||||
public readonly vm: UserMediaViewModel = this.participant$.value?.isLocal
|
public readonly vm: UserMediaViewModel =
|
||||||
? new LocalUserMediaViewModel(
|
this.participant.type === "local"
|
||||||
this.scope,
|
? new LocalUserMediaViewModel(
|
||||||
this.id,
|
this.scope,
|
||||||
this.userId,
|
this.id,
|
||||||
this.participant$ as Behavior<LocalParticipant | null>,
|
this.userId,
|
||||||
this.encryptionSystem,
|
this.participant.value$,
|
||||||
this.livekitRoom$,
|
this.encryptionSystem,
|
||||||
this.focusUrl$,
|
this.livekitRoom$,
|
||||||
this.mediaDevices,
|
this.focusUrl$,
|
||||||
this.displayName$,
|
this.mediaDevices,
|
||||||
this.mxcAvatarUrl$,
|
this.displayName$,
|
||||||
this.scope.behavior(this.handRaised$),
|
this.mxcAvatarUrl$,
|
||||||
this.scope.behavior(this.reaction$),
|
this.scope.behavior(this.handRaised$),
|
||||||
)
|
this.scope.behavior(this.reaction$),
|
||||||
: new RemoteUserMediaViewModel(
|
)
|
||||||
this.scope,
|
: new RemoteUserMediaViewModel(
|
||||||
this.id,
|
this.scope,
|
||||||
this.userId,
|
this.id,
|
||||||
this.participant$ as Behavior<RemoteParticipant | null>,
|
this.userId,
|
||||||
this.encryptionSystem,
|
this.participant.value$,
|
||||||
this.livekitRoom$,
|
this.encryptionSystem,
|
||||||
this.focusUrl$,
|
this.livekitRoom$,
|
||||||
this.pretendToBeDisconnected$,
|
this.focusUrl$,
|
||||||
this.displayName$,
|
this.pretendToBeDisconnected$,
|
||||||
this.mxcAvatarUrl$,
|
this.displayName$,
|
||||||
this.scope.behavior(this.handRaised$),
|
this.mxcAvatarUrl$,
|
||||||
this.scope.behavior(this.reaction$),
|
this.scope.behavior(this.handRaised$),
|
||||||
);
|
this.scope.behavior(this.reaction$),
|
||||||
|
);
|
||||||
|
|
||||||
private readonly speaker$ = this.scope.behavior(
|
private readonly speaker$ = this.scope.behavior(
|
||||||
observeSpeaker$(this.vm.speaking$),
|
observeSpeaker$(this.vm.speaking$),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TypeScript needs this widening of the type to happen in a separate statement
|
||||||
|
private readonly participant$: Behavior<
|
||||||
|
LocalParticipant | RemoteParticipant | null
|
||||||
|
> = this.participant.value$;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All screen share media associated with this user media.
|
* All screen share media associated with this user media.
|
||||||
*/
|
*/
|
||||||
@@ -184,9 +191,7 @@ export class UserMedia {
|
|||||||
private readonly scope: ObservableScope,
|
private readonly scope: ObservableScope,
|
||||||
public readonly id: string,
|
public readonly id: string,
|
||||||
private readonly userId: string,
|
private readonly userId: string,
|
||||||
private readonly participant$: Behavior<
|
private readonly participant: TaggedParticipant,
|
||||||
LocalParticipant | RemoteParticipant | null
|
|
||||||
>,
|
|
||||||
private readonly encryptionSystem: EncryptionSystem,
|
private readonly encryptionSystem: EncryptionSystem,
|
||||||
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||||
private readonly focusUrl$: Behavior<string | undefined>,
|
private readonly focusUrl$: Behavior<string | undefined>,
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { axe } from "vitest-axe";
|
|||||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||||
|
|
||||||
import { GridTile } from "./GridTile";
|
import { GridTile } from "./GridTile";
|
||||||
import { mockRtcMembership, createRemoteMedia } from "../utils/test";
|
import {
|
||||||
|
mockRtcMembership,
|
||||||
|
createRemoteMedia,
|
||||||
|
mockRemoteParticipant,
|
||||||
|
} from "../utils/test";
|
||||||
import { GridTileViewModel } from "../state/TileViewModel";
|
import { GridTileViewModel } from "../state/TileViewModel";
|
||||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||||
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
@@ -31,11 +35,11 @@ test("GridTile is accessible", async () => {
|
|||||||
rawDisplayName: "Alice",
|
rawDisplayName: "Alice",
|
||||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
},
|
},
|
||||||
{
|
mockRemoteParticipant({
|
||||||
setVolume() {},
|
setVolume() {},
|
||||||
getTrackPublication: () =>
|
getTrackPublication: () =>
|
||||||
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fakeRtcSession = {
|
const fakeRtcSession = {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface UserMediaTileProps extends TileProps {
|
|||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
mirror: boolean;
|
mirror: boolean;
|
||||||
locallyMuted: boolean;
|
locallyMuted: boolean;
|
||||||
|
waitingForMedia?: boolean;
|
||||||
primaryButton?: ReactNode;
|
primaryButton?: ReactNode;
|
||||||
menuStart?: ReactNode;
|
menuStart?: ReactNode;
|
||||||
menuEnd?: ReactNode;
|
menuEnd?: ReactNode;
|
||||||
@@ -79,6 +80,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
vm,
|
vm,
|
||||||
showSpeakingIndicators,
|
showSpeakingIndicators,
|
||||||
locallyMuted,
|
locallyMuted,
|
||||||
|
waitingForMedia,
|
||||||
primaryButton,
|
primaryButton,
|
||||||
menuStart,
|
menuStart,
|
||||||
menuEnd,
|
menuEnd,
|
||||||
@@ -148,7 +150,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
const tile = (
|
const tile = (
|
||||||
<MediaView
|
<MediaView
|
||||||
ref={ref}
|
ref={ref}
|
||||||
video={video ?? undefined}
|
video={video}
|
||||||
userId={vm.userId}
|
userId={vm.userId}
|
||||||
unencryptedWarning={unencryptedWarning}
|
unencryptedWarning={unencryptedWarning}
|
||||||
encryptionStatus={encryptionStatus}
|
encryptionStatus={encryptionStatus}
|
||||||
@@ -194,7 +196,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
|||||||
raisedHandTime={handRaised ?? undefined}
|
raisedHandTime={handRaised ?? undefined}
|
||||||
currentReaction={reaction ?? undefined}
|
currentReaction={reaction ?? undefined}
|
||||||
raisedHandOnClick={raisedHandOnClick}
|
raisedHandOnClick={raisedHandOnClick}
|
||||||
localParticipant={vm.local}
|
waitingForMedia={waitingForMedia}
|
||||||
focusUrl={focusUrl}
|
focusUrl={focusUrl}
|
||||||
audioStreamStats={audioStreamStats}
|
audioStreamStats={audioStreamStats}
|
||||||
videoStreamStats={videoStreamStats}
|
videoStreamStats={videoStreamStats}
|
||||||
@@ -290,6 +292,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||||
const localVolume = useBehavior(vm.localVolume$);
|
const localVolume = useBehavior(vm.localVolume$);
|
||||||
const onSelectMute = useCallback(
|
const onSelectMute = useCallback(
|
||||||
@@ -311,6 +314,7 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
|||||||
<UserMediaTile
|
<UserMediaTile
|
||||||
ref={ref}
|
ref={ref}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
|
waitingForMedia={waitingForMedia}
|
||||||
locallyMuted={locallyMuted}
|
locallyMuted={locallyMuted}
|
||||||
mirror={false}
|
mirror={false}
|
||||||
menuStart={
|
menuStart={
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ describe("MediaView", () => {
|
|||||||
video: trackReference,
|
video: trackReference,
|
||||||
userId: "@alice:example.com",
|
userId: "@alice:example.com",
|
||||||
mxcAvatarUrl: undefined,
|
mxcAvatarUrl: undefined,
|
||||||
localParticipant: false,
|
|
||||||
focusable: true,
|
focusable: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,24 +65,13 @@ describe("MediaView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with no participant", () => {
|
describe("with no video", () => {
|
||||||
it("shows avatar for local user", () => {
|
it("shows avatar", () => {
|
||||||
render(
|
render(<MediaView {...baseProps} video={undefined} />);
|
||||||
<MediaView {...baseProps} video={undefined} localParticipant={true} />,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
screen.getByRole("img", { name: "@alice:example.com" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
|
expect(screen.queryByTestId("video")).toBe(null);
|
||||||
});
|
|
||||||
it("shows avatar and label for remote user", () => {
|
|
||||||
render(
|
|
||||||
<MediaView {...baseProps} video={undefined} localParticipant={false} />,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("img", { name: "@alice:example.com" }),
|
|
||||||
).toBeVisible();
|
|
||||||
expect(screen.getByText("Waiting for media...")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,6 +82,22 @@ describe("MediaView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("waitingForMedia", () => {
|
||||||
|
test("defaults to false", () => {
|
||||||
|
render(<MediaView {...baseProps} />);
|
||||||
|
expect(screen.queryAllByText("Waiting for media...").length).toBe(0);
|
||||||
|
});
|
||||||
|
test("shows and is accessible", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<MediaView {...baseProps} waitingForMedia={true} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
expect(await axe(container)).toHaveNoViolations();
|
||||||
|
expect(screen.getByText("Waiting for media...")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("unencryptedWarning", () => {
|
describe("unencryptedWarning", () => {
|
||||||
test("is shown and accessible", async () => {
|
test("is shown and accessible", async () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
|||||||
raisedHandTime?: Date;
|
raisedHandTime?: Date;
|
||||||
currentReaction?: ReactionOption;
|
currentReaction?: ReactionOption;
|
||||||
raisedHandOnClick?: () => void;
|
raisedHandOnClick?: () => void;
|
||||||
localParticipant: boolean;
|
waitingForMedia?: boolean;
|
||||||
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||||
// The focus url, mainly for debugging purposes
|
// The focus url, mainly for debugging purposes
|
||||||
@@ -71,7 +71,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
raisedHandTime,
|
raisedHandTime,
|
||||||
currentReaction,
|
currentReaction,
|
||||||
raisedHandOnClick,
|
raisedHandOnClick,
|
||||||
localParticipant,
|
waitingForMedia,
|
||||||
audioStreamStats,
|
audioStreamStats,
|
||||||
videoStreamStats,
|
videoStreamStats,
|
||||||
focusUrl,
|
focusUrl,
|
||||||
@@ -129,7 +129,7 @@ export const MediaView: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!video && !localParticipant && (
|
{waitingForMedia && (
|
||||||
<div className={styles.status}>
|
<div className={styles.status}>
|
||||||
{t("video_tile.waiting_for_media")}
|
{t("video_tile.waiting_for_media")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
createLocalMedia,
|
createLocalMedia,
|
||||||
createRemoteMedia,
|
createRemoteMedia,
|
||||||
|
mockRemoteParticipant,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
@@ -33,7 +34,7 @@ test("SpotlightTile is accessible", async () => {
|
|||||||
rawDisplayName: "Alice",
|
rawDisplayName: "Alice",
|
||||||
getMxcAvatarUrl: () => "mxc://adfsg",
|
getMxcAvatarUrl: () => "mxc://adfsg",
|
||||||
},
|
},
|
||||||
{},
|
mockRemoteParticipant({}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const vm2 = createLocalMedia(
|
const vm2 = createLocalMedia(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
type MediaViewModel,
|
type MediaViewModel,
|
||||||
ScreenShareViewModel,
|
ScreenShareViewModel,
|
||||||
type UserMediaViewModel,
|
type UserMediaViewModel,
|
||||||
|
type RemoteUserMediaViewModel,
|
||||||
} from "../state/MediaViewModel";
|
} from "../state/MediaViewModel";
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
@@ -84,6 +85,21 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
|||||||
|
|
||||||
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||||
|
|
||||||
|
interface SpotlightRemoteUserMediaItemProps
|
||||||
|
extends SpotlightUserMediaItemBaseProps {
|
||||||
|
vm: RemoteUserMediaViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SpotlightRemoteUserMediaItem: FC<SpotlightRemoteUserMediaItemProps> = ({
|
||||||
|
vm,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||||
|
return (
|
||||||
|
<MediaView waitingForMedia={waitingForMedia} mirror={false} {...props} />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
||||||
vm: UserMediaViewModel;
|
vm: UserMediaViewModel;
|
||||||
}
|
}
|
||||||
@@ -103,7 +119,7 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
|||||||
return vm instanceof LocalUserMediaViewModel ? (
|
return vm instanceof LocalUserMediaViewModel ? (
|
||||||
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
||||||
) : (
|
) : (
|
||||||
<MediaView mirror={false} {...baseProps} />
|
<SpotlightRemoteUserMediaItem vm={vm} {...baseProps} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import * as controls from "./controls";
|
|||||||
* Play a sound though a given AudioContext. Will take
|
* Play a sound though a given AudioContext. Will take
|
||||||
* care of connecting the correct buffer and gating
|
* care of connecting the correct buffer and gating
|
||||||
* through gain.
|
* through gain.
|
||||||
* @param volume The volume to play at.
|
|
||||||
* @param ctx The context to play through.
|
* @param ctx The context to play through.
|
||||||
* @param buffer The buffer to play.
|
* @param buffer The buffer to play.
|
||||||
|
* @param volume The volume to play at.
|
||||||
|
* @param stereoPan The stereo pan to apply.
|
||||||
|
* @param delayS Delay in seconds before starting playing.
|
||||||
|
* @param abort Optional AbortController that can be used to stop playback.
|
||||||
* @returns A promise that resolves when the sound has finished playing.
|
* @returns A promise that resolves when the sound has finished playing.
|
||||||
*/
|
*/
|
||||||
async function playSound(
|
async function playSound(
|
||||||
@@ -55,9 +58,11 @@ async function playSound(
|
|||||||
* Play a sound though a given AudioContext, looping until stopped. Will take
|
* Play a sound though a given AudioContext, looping until stopped. Will take
|
||||||
* care of connecting the correct buffer and gating
|
* care of connecting the correct buffer and gating
|
||||||
* through gain.
|
* through gain.
|
||||||
* @param volume The volume to play at.
|
|
||||||
* @param ctx The context to play through.
|
* @param ctx The context to play through.
|
||||||
* @param buffer The buffer to play.
|
* @param buffer The buffer to play.
|
||||||
|
* @param volume The volume to play at.
|
||||||
|
* @param stereoPan The stereo pan to apply.
|
||||||
|
* @param delayS Delay in seconds between each loop.
|
||||||
* @returns A function used to end the sound. This function will return a promise when the sound has stopped.
|
* @returns A function used to end the sound. This function will return a promise when the sound has stopped.
|
||||||
*/
|
*/
|
||||||
function playSoundLooping(
|
function playSoundLooping(
|
||||||
@@ -120,7 +125,7 @@ interface UseAudioContext<S extends string> {
|
|||||||
/**
|
/**
|
||||||
* Add an audio context which can be used to play
|
* Add an audio context which can be used to play
|
||||||
* a set of preloaded sounds.
|
* a set of preloaded sounds.
|
||||||
* @param props
|
* @param props The properties for the audio context.
|
||||||
* @returns Either an instance that can be used to play sounds, or null if not ready.
|
* @returns Either an instance that can be used to play sounds, or null if not ready.
|
||||||
*/
|
*/
|
||||||
export function useAudioContext<S extends string>(
|
export function useAudioContext<S extends string>(
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ export function shouldDisambiguate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a display name for a member, optionally disambiguating it.
|
||||||
|
* @param member - The member to calculate the display name for.
|
||||||
|
* @param member.rawDisplayName - The raw display name of the member
|
||||||
|
* @param member.userId - The user ID of the member
|
||||||
|
* @param disambiguate - Whether to disambiguate the display name.
|
||||||
|
*/
|
||||||
export function calculateDisplayName(
|
export function calculateDisplayName(
|
||||||
member: { rawDisplayName?: string; userId: string },
|
member: { rawDisplayName?: string; userId: string },
|
||||||
disambiguate: boolean,
|
disambiguate: boolean,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export enum ErrorCode {
|
|||||||
*/
|
*/
|
||||||
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
|
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
|
||||||
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
|
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
|
||||||
|
INTERNAL_MEMBERSHIP_MANAGER = "INTERNAL_MEMBERSHIP_MANAGER",
|
||||||
|
FAILED_TO_START_LIVEKIT = "FAILED_TO_START_LIVEKIT",
|
||||||
/** LiveKit indicates that the server has hit its track limits */
|
/** LiveKit indicates that the server has hit its track limits */
|
||||||
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
||||||
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
||||||
@@ -27,6 +29,7 @@ export enum ErrorCategory {
|
|||||||
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
|
NETWORK_CONNECTIVITY = "NETWORK_CONNECTIVITY",
|
||||||
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
|
CLIENT_CONFIGURATION = "CLIENT_CONFIGURATION",
|
||||||
UNKNOWN = "UNKNOWN",
|
UNKNOWN = "UNKNOWN",
|
||||||
|
SYSTEM_FAILURE = "SYSTEM_FAILURE",
|
||||||
// SYSTEM_FAILURE / FEDERATION_FAILURE ..
|
// SYSTEM_FAILURE / FEDERATION_FAILURE ..
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +57,16 @@ export class ElementCallError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration problem due to no MatrixRTC backend/SFU is exposed via .well-known and no fallback configured.
|
||||||
|
*/
|
||||||
export class MatrixRTCTransportMissingError extends ElementCallError {
|
export class MatrixRTCTransportMissingError extends ElementCallError {
|
||||||
public domain: string;
|
public domain: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an instance of MatrixRTCTransportMissingError.
|
||||||
|
* @param domain - The domain where the MatrixRTC transport is missing.
|
||||||
|
*/
|
||||||
public constructor(domain: string) {
|
public constructor(domain: string) {
|
||||||
super(
|
super(
|
||||||
t("error.call_is_not_supported"),
|
t("error.call_is_not_supported"),
|
||||||
@@ -72,6 +82,9 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating that the connection to the call was lost and could not be re-established.
|
||||||
|
*/
|
||||||
export class ConnectionLostError extends ElementCallError {
|
export class ConnectionLostError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
@@ -83,6 +96,30 @@ export class ConnectionLostError extends ElementCallError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating a failure in the membership manager causing the join call
|
||||||
|
* operation to fail.
|
||||||
|
*/
|
||||||
|
export class MembershipManagerError extends ElementCallError {
|
||||||
|
/**
|
||||||
|
* Creates an instance of MembershipManagerError.
|
||||||
|
*
|
||||||
|
* @param error - The underlying error that caused the membership manager failure.
|
||||||
|
*/
|
||||||
|
public constructor(error: Error) {
|
||||||
|
super(
|
||||||
|
t("error.membership_manager"),
|
||||||
|
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
|
||||||
|
ErrorCategory.SYSTEM_FAILURE,
|
||||||
|
t("error.membership_manager_description"),
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating that end-to-end encryption is not supported in the current environment.
|
||||||
|
*/
|
||||||
export class E2EENotSupportedError extends ElementCallError {
|
export class E2EENotSupportedError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
@@ -94,7 +131,14 @@ export class E2EENotSupportedError extends ElementCallError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating an unknown issue occurred during a call operation.
|
||||||
|
*/
|
||||||
export class UnknownCallError extends ElementCallError {
|
export class UnknownCallError extends ElementCallError {
|
||||||
|
/**
|
||||||
|
* Creates an instance of UnknownCallError.
|
||||||
|
* @param error - The underlying error that caused the unknown issue.
|
||||||
|
*/
|
||||||
public constructor(error: Error) {
|
public constructor(error: Error) {
|
||||||
super(
|
super(
|
||||||
t("error.generic"),
|
t("error.generic"),
|
||||||
@@ -107,7 +151,14 @@ export class UnknownCallError extends ElementCallError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating a failure to obtain an OpenID token.
|
||||||
|
*/
|
||||||
export class FailToGetOpenIdToken extends ElementCallError {
|
export class FailToGetOpenIdToken extends ElementCallError {
|
||||||
|
/**
|
||||||
|
* Creates an instance of FailToGetOpenIdToken.
|
||||||
|
* @param error - The underlying error that caused the failure.
|
||||||
|
*/
|
||||||
public constructor(error: Error) {
|
public constructor(error: Error) {
|
||||||
super(
|
super(
|
||||||
t("error.generic"),
|
t("error.generic"),
|
||||||
@@ -120,6 +171,27 @@ export class FailToGetOpenIdToken extends ElementCallError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating a failure to start publishing on a LiveKit connection.
|
||||||
|
*/
|
||||||
|
export class FailToStartLivekitConnection extends ElementCallError {
|
||||||
|
/**
|
||||||
|
* Creates an instance of FailToStartLivekitConnection.
|
||||||
|
* @param e - An optional error message providing additional context.
|
||||||
|
*/
|
||||||
|
public constructor(e?: string) {
|
||||||
|
super(
|
||||||
|
t("error.failed_to_start_livekit"),
|
||||||
|
ErrorCode.FAILED_TO_START_LIVEKIT,
|
||||||
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating that a LiveKit's server has hit its track limits.
|
||||||
|
*/
|
||||||
export class InsufficientCapacityError extends ElementCallError {
|
export class InsufficientCapacityError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
@@ -131,6 +203,10 @@ export class InsufficientCapacityError extends ElementCallError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error indicating that room creation is restricted by the SFU.
|
||||||
|
* Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||||
|
*/
|
||||||
export class SFURoomCreationRestrictedError extends ElementCallError {
|
export class SFURoomCreationRestrictedError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ function fullAliasFromRoomName(roomName: string, client: MatrixClient): string {
|
|||||||
* Applies some basic sanitisation to a room name that the user
|
* Applies some basic sanitisation to a room name that the user
|
||||||
* has given us
|
* has given us
|
||||||
* @param input The room name from the user
|
* @param input The room name from the user
|
||||||
* @param client A matrix client object
|
|
||||||
*/
|
*/
|
||||||
export function sanitiseRoomNameInput(input: string): string {
|
export function sanitiseRoomNameInput(input: string): string {
|
||||||
// check to see if the user has entered a fully qualified room
|
// check to see if the user has entered a fully qualified room
|
||||||
@@ -304,8 +303,9 @@ export async function createRoom(
|
|||||||
/**
|
/**
|
||||||
* Returns an absolute URL to that will load Element Call with the given room
|
* Returns an absolute URL to that will load Element Call with the given room
|
||||||
* @param roomId ID of the room
|
* @param roomId ID of the room
|
||||||
* @param roomName Name of the room
|
|
||||||
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||||
|
* @param roomName Name of the room
|
||||||
|
* @param viaServers Optional list of servers to include as 'via' parameters in the URL
|
||||||
*/
|
*/
|
||||||
export function getAbsoluteRoomUrl(
|
export function getAbsoluteRoomUrl(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
@@ -321,8 +321,9 @@ export function getAbsoluteRoomUrl(
|
|||||||
/**
|
/**
|
||||||
* Returns a relative URL to that will load Element Call with the given room
|
* Returns a relative URL to that will load Element Call with the given room
|
||||||
* @param roomId ID of the room
|
* @param roomId ID of the room
|
||||||
* @param roomName Name of the room
|
|
||||||
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
* @param encryptionSystem what encryption (or EncryptionSystem.Unencrypted) the room uses
|
||||||
|
* @param roomName Name of the room
|
||||||
|
* @param viaServers Optional list of servers to include as 'via' parameters in the URL
|
||||||
*/
|
*/
|
||||||
export function getRelativeRoomUrl(
|
export function getRelativeRoomUrl(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
/**
|
/**
|
||||||
* Finds a media device with label matching 'deviceName'
|
* Finds a media device with label matching 'deviceName'
|
||||||
* @param deviceName The label of the device to look for
|
* @param deviceName The label of the device to look for
|
||||||
|
* @param kind The kind of media device to look for
|
||||||
* @param devices The list of devices to search
|
* @param devices The list of devices to search
|
||||||
* @returns A matching media device or undefined if no matching device was found
|
* @returns A matching media device or undefined if no matching device was found
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { Subject } from "rxjs";
|
import { type Observable, of, Subject, switchMap } from "rxjs";
|
||||||
|
|
||||||
import { withTestScheduler } from "./test";
|
import { withTestScheduler } from "./test";
|
||||||
import { generateItems, pauseWhen } from "./observable";
|
import { filterBehavior, generateItems, pauseWhen } from "./observable";
|
||||||
|
import { type Behavior } from "../state/Behavior";
|
||||||
|
|
||||||
test("pauseWhen", () => {
|
test("pauseWhen", () => {
|
||||||
withTestScheduler(({ behavior, expectObservable }) => {
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
@@ -72,3 +73,31 @@ test("generateItems", () => {
|
|||||||
expectObservable(scope4$).toBe(scope4Marbles);
|
expectObservable(scope4$).toBe(scope4Marbles);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("filterBehavior", () => {
|
||||||
|
withTestScheduler(({ behavior, expectObservable }) => {
|
||||||
|
// Filtering the input should segment it into 2 modes of non-null behavior.
|
||||||
|
const inputMarbles = " abcxabx";
|
||||||
|
const filteredMarbles = "a--xa-x";
|
||||||
|
|
||||||
|
const input$ = behavior(inputMarbles, {
|
||||||
|
a: "a",
|
||||||
|
b: "b",
|
||||||
|
c: "c",
|
||||||
|
x: null,
|
||||||
|
});
|
||||||
|
const filtered$: Observable<Behavior<string> | null> = input$.pipe(
|
||||||
|
filterBehavior((value) => typeof value === "string"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expectObservable(filtered$).toBe(filteredMarbles, {
|
||||||
|
a: expect.any(Object),
|
||||||
|
x: null,
|
||||||
|
});
|
||||||
|
expectObservable(
|
||||||
|
filtered$.pipe(
|
||||||
|
switchMap((value$) => (value$ === null ? of(null) : value$)),
|
||||||
|
),
|
||||||
|
).toBe(inputMarbles, { a: "a", b: "b", c: "c", x: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
withLatestFrom,
|
withLatestFrom,
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
type OperatorFunction,
|
type OperatorFunction,
|
||||||
|
distinctUntilChanged,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { type Behavior } from "../state/Behavior";
|
import { type Behavior } from "../state/Behavior";
|
||||||
@@ -134,7 +135,6 @@ interface ItemHandle<Data, Item> {
|
|||||||
* requested at a later time, and destroyed (have their scope ended) when the
|
* requested at a later time, and destroyed (have their scope ended) when the
|
||||||
* key is no longer requested.
|
* key is no longer requested.
|
||||||
*
|
*
|
||||||
* @param input$ The input value to be mapped.
|
|
||||||
* @param generator A generator function yielding a tuple of keys and the
|
* @param generator A generator function yielding a tuple of keys and the
|
||||||
* currently associated data for each item that it wants to exist.
|
* currently associated data for each item that it wants to exist.
|
||||||
* @param factory A function constructing an individual item, given the item's key,
|
* @param factory A function constructing an individual item, given the item's key,
|
||||||
@@ -185,6 +185,28 @@ export function generateItemsWithEpoch<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segments a behavior into periods during which its value matches the filter
|
||||||
|
* (outputting a behavior with a narrowed type) and periods during which it does
|
||||||
|
* not match (outputting null).
|
||||||
|
*/
|
||||||
|
export function filterBehavior<T, S extends T>(
|
||||||
|
predicate: (value: T) => value is S,
|
||||||
|
): OperatorFunction<T, Behavior<S> | null> {
|
||||||
|
return (input$) =>
|
||||||
|
input$.pipe(
|
||||||
|
scan<T, BehaviorSubject<S> | null>((acc$, input) => {
|
||||||
|
if (predicate(input)) {
|
||||||
|
const output$ = acc$ ?? new BehaviorSubject(input);
|
||||||
|
output$.next(input);
|
||||||
|
return output$;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, null),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function generateItemsInternal<
|
function generateItemsInternal<
|
||||||
Input,
|
Input,
|
||||||
Keys extends [unknown, ...unknown[]],
|
Keys extends [unknown, ...unknown[]],
|
||||||
|
|||||||
@@ -59,3 +59,17 @@ export const daveRTLRtcMember = mockRtcMembership("@dave2:example.org", "DDDD");
|
|||||||
export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
|
export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
|
||||||
rawDisplayName: "\u202eevaD",
|
rawDisplayName: "\u202eevaD",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const testJWTToken = [
|
||||||
|
{}, // header
|
||||||
|
{
|
||||||
|
// payload
|
||||||
|
sub: "@me:example.org:ABCDEF",
|
||||||
|
video: {
|
||||||
|
room: "!example_room_id",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{}, // signature
|
||||||
|
]
|
||||||
|
.map((d) => global.btoa(JSON.stringify(d)))
|
||||||
|
.join(".");
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import { aliceRtcMember, localRtcMember } from "./test-fixtures";
|
import { aliceRtcMember, localRtcMember } from "./test-fixtures";
|
||||||
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
|
import { MatrixRTCMode } from "../settings/settings";
|
||||||
|
|
||||||
mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
|
mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
|
||||||
|
|
||||||
@@ -162,6 +163,7 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
setE2EEEnabled: async () => Promise.resolve(),
|
setE2EEEnabled: async () => Promise.resolve(),
|
||||||
}),
|
}),
|
||||||
connectionState$: constant(ConnectionState.Connected),
|
connectionState$: constant(ConnectionState.Connected),
|
||||||
|
matrixRTCMode$: constant(MatrixRTCMode.Legacy),
|
||||||
...callViewModelOptions,
|
...callViewModelOptions,
|
||||||
},
|
},
|
||||||
handRaisedSubject$,
|
handRaisedSubject$,
|
||||||
|
|||||||
@@ -44,12 +44,12 @@ import {
|
|||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import {
|
|
||||||
type RoomAndToDeviceEvents,
|
|
||||||
type RoomAndToDeviceEventsHandlerMap,
|
|
||||||
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
|
||||||
import { type TrackReference } from "@livekit/components-core";
|
import { type TrackReference } from "@livekit/components-core";
|
||||||
import EventEmitter from "events";
|
import EventEmitter from "events";
|
||||||
|
import {
|
||||||
|
type KeyTransportEvents,
|
||||||
|
type KeyTransportEventsHandlerMap,
|
||||||
|
} from "matrix-js-sdk/lib/matrixrtc/IKeyTransport";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
LocalUserMediaViewModel,
|
LocalUserMediaViewModel,
|
||||||
@@ -284,6 +284,8 @@ export function mockLivekitRoom(
|
|||||||
): LivekitRoom {
|
): LivekitRoom {
|
||||||
const livekitRoom = {
|
const livekitRoom = {
|
||||||
options: {},
|
options: {},
|
||||||
|
setE2EEEnabled: vi.fn(),
|
||||||
|
|
||||||
...mockEmitter(),
|
...mockEmitter(),
|
||||||
...room,
|
...room,
|
||||||
} as Partial<LivekitRoom> as LivekitRoom;
|
} as Partial<LivekitRoom> as LivekitRoom;
|
||||||
@@ -306,7 +308,11 @@ export function mockLocalParticipant(
|
|||||||
return {
|
return {
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
trackPublications: new Map(),
|
trackPublications: new Map(),
|
||||||
unpublishTracks: async () => Promise.resolve(),
|
publishTrack: vi.fn(),
|
||||||
|
unpublishTracks: vi.fn().mockResolvedValue([]),
|
||||||
|
createTracks: vi.fn(),
|
||||||
|
setMicrophoneEnabled: vi.fn(),
|
||||||
|
setCameraEnabled: vi.fn(),
|
||||||
getTrackPublication: () =>
|
getTrackPublication: () =>
|
||||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||||
...mockEmitter(),
|
...mockEmitter(),
|
||||||
@@ -315,12 +321,12 @@ export function mockLocalParticipant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createLocalMedia(
|
export function createLocalMedia(
|
||||||
localRtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
localParticipant: LocalParticipant,
|
localParticipant: LocalParticipant,
|
||||||
mediaDevices: MediaDevices,
|
mediaDevices: MediaDevices,
|
||||||
): LocalUserMediaViewModel {
|
): LocalUserMediaViewModel {
|
||||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||||
return new LocalUserMediaViewModel(
|
return new LocalUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"local",
|
"local",
|
||||||
@@ -355,23 +361,26 @@ export function mockRemoteParticipant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRemoteMedia(
|
export function createRemoteMedia(
|
||||||
localRtcMember: CallMembership,
|
rtcMember: CallMembership,
|
||||||
roomMember: Partial<RoomMember>,
|
roomMember: Partial<RoomMember>,
|
||||||
participant: Partial<RemoteParticipant>,
|
participant: RemoteParticipant | null,
|
||||||
|
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
remoteParticipants$: of(participant ? [participant] : []),
|
||||||
|
},
|
||||||
|
),
|
||||||
): RemoteUserMediaViewModel {
|
): RemoteUserMediaViewModel {
|
||||||
const member = mockMatrixRoomMember(localRtcMember, roomMember);
|
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||||
const remoteParticipant = mockRemoteParticipant(participant);
|
|
||||||
return new RemoteUserMediaViewModel(
|
return new RemoteUserMediaViewModel(
|
||||||
testScope(),
|
testScope(),
|
||||||
"remote",
|
"remote",
|
||||||
member.userId,
|
member.userId,
|
||||||
of(remoteParticipant),
|
constant(participant),
|
||||||
{
|
{
|
||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
constant(
|
constant(livekitRoom),
|
||||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
|
||||||
),
|
|
||||||
constant("https://rtc-example.org"),
|
constant("https://rtc-example.org"),
|
||||||
constant(false),
|
constant(false),
|
||||||
constant(member.rawDisplayName ?? "nodisplayname"),
|
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||||
@@ -394,9 +403,9 @@ export function mockConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MockRTCSession extends TypedEventEmitter<
|
export class MockRTCSession extends TypedEventEmitter<
|
||||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
MatrixRTCSessionEvent | MembershipManagerEvent | KeyTransportEvents,
|
||||||
MatrixRTCSessionEventHandlerMap &
|
KeyTransportEventsHandlerMap &
|
||||||
RoomAndToDeviceEventsHandlerMap &
|
MatrixRTCSessionEventHandlerMap &
|
||||||
MembershipManagerEventHandlerMap
|
MembershipManagerEventHandlerMap
|
||||||
> {
|
> {
|
||||||
public asMockedSession(): MockedObject<MatrixRTCSession> {
|
public asMockedSession(): MockedObject<MatrixRTCSession> {
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export const widget = ((): WidgetHelpers | null => {
|
|||||||
try {
|
try {
|
||||||
const { widgetId, parentUrl } = getUrlParams();
|
const { widgetId, parentUrl } = getUrlParams();
|
||||||
|
|
||||||
|
const { roomId, userId, deviceId, baseUrl, e2eEnabled, allowIceFallback } =
|
||||||
|
getUrlParams();
|
||||||
|
if (!roomId) throw new Error("Room ID must be supplied");
|
||||||
|
if (!userId) throw new Error("User ID must be supplied");
|
||||||
|
if (!deviceId) throw new Error("Device ID must be supplied");
|
||||||
|
if (!baseUrl) throw new Error("Base URL must be supplied");
|
||||||
if (widgetId && parentUrl) {
|
if (widgetId && parentUrl) {
|
||||||
const parentOrigin = new URL(parentUrl).origin;
|
const parentOrigin = new URL(parentUrl).origin;
|
||||||
logger.info("Widget API is available");
|
logger.info("Widget API is available");
|
||||||
@@ -92,19 +98,6 @@ export const widget = ((): WidgetHelpers | null => {
|
|||||||
// We need to do this now rather than later because it has capabilities to
|
// We need to do this now rather than later because it has capabilities to
|
||||||
// request, and is responsible for starting the transport (should it be?)
|
// request, and is responsible for starting the transport (should it be?)
|
||||||
|
|
||||||
const {
|
|
||||||
roomId,
|
|
||||||
userId,
|
|
||||||
deviceId,
|
|
||||||
baseUrl,
|
|
||||||
e2eEnabled,
|
|
||||||
allowIceFallback,
|
|
||||||
} = getUrlParams();
|
|
||||||
if (!roomId) throw new Error("Room ID must be supplied");
|
|
||||||
if (!userId) throw new Error("User ID must be supplied");
|
|
||||||
if (!deviceId) throw new Error("Device ID must be supplied");
|
|
||||||
if (!baseUrl) throw new Error("Base URL must be supplied");
|
|
||||||
|
|
||||||
// These are all the event types the app uses
|
// These are all the event types the app uses
|
||||||
const sendEvent = [
|
const sendEvent = [
|
||||||
EventType.CallNotify, // Sent as a deprecated fallback
|
EventType.CallNotify, // Sent as a deprecated fallback
|
||||||
|
|||||||
@@ -50,6 +50,11 @@
|
|||||||
|
|
||||||
"plugins": [{ "name": "typescript-eslint-language-service" }]
|
"plugins": [{ "name": "typescript-eslint-language-service" }]
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts"],
|
"include": [
|
||||||
|
"./src/**/*.ts",
|
||||||
|
"./src/**/*.tsx",
|
||||||
|
"./playwright/**/*.ts",
|
||||||
|
"./sdk/**/*.ts"
|
||||||
|
],
|
||||||
"exclude": ["**.test.ts"]
|
"exclude": ["**.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
28
vite-sdk.config.ts
Normal file
28
vite-sdk.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineConfig, mergeConfig } from "vite";
|
||||||
|
import nodePolyfills from "vite-plugin-node-stdlib-browser";
|
||||||
|
|
||||||
|
const base = "./";
|
||||||
|
|
||||||
|
// Config for embedded deployments (possibly hosted under a non-root path)
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
worker: { format: "es" as const },
|
||||||
|
base, // Use relative URLs to allow the app to be hosted under any path
|
||||||
|
build: {
|
||||||
|
sourcemap: true,
|
||||||
|
manifest: true,
|
||||||
|
lib: {
|
||||||
|
formats: ["es" as const],
|
||||||
|
entry: "./sdk/main.ts",
|
||||||
|
name: "MatrixrtcSdk",
|
||||||
|
fileName: "matrixrtc-sdk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [nodePolyfills()],
|
||||||
|
}));
|
||||||
@@ -7,14 +7,17 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
loadEnv,
|
loadEnv,
|
||||||
|
PluginOption,
|
||||||
searchForWorkspaceRoot,
|
searchForWorkspaceRoot,
|
||||||
type ConfigEnv,
|
type ConfigEnv,
|
||||||
type UserConfig,
|
type UserConfig,
|
||||||
} from "vite";
|
} from "vite";
|
||||||
import svgrPlugin from "vite-plugin-svgr";
|
import svgrPlugin from "vite-plugin-svgr";
|
||||||
import { createHtmlPlugin } from "vite-plugin-html";
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
|
|
||||||
import { codecovVitePlugin } from "@codecov/vite-plugin";
|
import { codecovVitePlugin } from "@codecov/vite-plugin";
|
||||||
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
import { sentryVitePlugin } from "@sentry/vite-plugin";
|
||||||
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import { realpathSync } from "fs";
|
import { realpathSync } from "fs";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
@@ -31,7 +34,7 @@ export default ({
|
|||||||
// In future we might be able to do what is needed via code splitting at
|
// In future we might be able to do what is needed via code splitting at
|
||||||
// build time.
|
// build time.
|
||||||
process.env.VITE_PACKAGE = packageType ?? "full";
|
process.env.VITE_PACKAGE = packageType ?? "full";
|
||||||
const plugins = [
|
const plugins: PluginOption[] = [
|
||||||
react(),
|
react(),
|
||||||
svgrPlugin({
|
svgrPlugin({
|
||||||
svgrOptions: {
|
svgrOptions: {
|
||||||
@@ -41,16 +44,6 @@ export default ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createHtmlPlugin({
|
|
||||||
entry: "src/main.tsx",
|
|
||||||
inject: {
|
|
||||||
data: {
|
|
||||||
brand: env.VITE_PRODUCT_NAME || "Element Call",
|
|
||||||
packageType: process.env.VITE_PACKAGE,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
codecovVitePlugin({
|
codecovVitePlugin({
|
||||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
||||||
bundleName: "element-call",
|
bundleName: "element-call",
|
||||||
@@ -73,6 +66,18 @@ export default ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
plugins.push(
|
||||||
|
createHtmlPlugin({
|
||||||
|
entry: "src/main.tsx",
|
||||||
|
inject: {
|
||||||
|
data: {
|
||||||
|
brand: env.VITE_PRODUCT_NAME || "Element Call",
|
||||||
|
packageType: process.env.VITE_PACKAGE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// The crypto WASM module is imported dynamically. Since it's common
|
// The crypto WASM module is imported dynamically. Since it's common
|
||||||
// for developers to use a linked copy of matrix-js-sdk or Rust
|
// for developers to use a linked copy of matrix-js-sdk or Rust
|
||||||
// crypto (which could reside anywhere on their file system), Vite
|
// crypto (which could reside anywhere on their file system), Vite
|
||||||
|
|||||||
Reference in New Issue
Block a user