mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
136 Commits
toger5/cod
...
v0.18.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af54b39698 | ||
|
|
c05d223133 | ||
|
|
c7f25feb66 | ||
|
|
a20edca9a1 | ||
|
|
d00ff78d65 | ||
|
|
839c4dd738 | ||
|
|
41f7b643fb | ||
|
|
3a9d394529 | ||
|
|
c9557e91d5 | ||
|
|
1e400bc550 | ||
|
|
6485da8fff | ||
|
|
54bef07b3b | ||
|
|
273eedd256 | ||
|
|
38382539ad | ||
|
|
8db1c4c370 | ||
|
|
c98d03e2e1 | ||
|
|
143b560b20 | ||
|
|
3da762ab36 | ||
|
|
efcde9edfe | ||
|
|
ca3837f44e | ||
|
|
467675ff40 | ||
|
|
5a612fea91 | ||
|
|
17ec488f0b | ||
|
|
8f418ce9fd | ||
|
|
4c7fad30c0 | ||
|
|
4922249f41 | ||
|
|
06c15ded37 | ||
|
|
885a523e91 | ||
|
|
313b8285d9 | ||
|
|
d00e1ca983 | ||
|
|
513477d280 | ||
|
|
273fff20bd | ||
|
|
3bbbac23a0 | ||
|
|
c7a16e9dfd | ||
|
|
5f2d1c8a7e | ||
|
|
e99e8628d6 | ||
|
|
65045c264b | ||
|
|
3216d68470 | ||
|
|
99401a7285 | ||
|
|
c74d19ad33 | ||
|
|
ea074ceb2d | ||
|
|
b88daf0198 | ||
|
|
a34fe7817a | ||
|
|
72520dbb3f | ||
|
|
2cf4a38c17 | ||
|
|
00f880108e | ||
|
|
a2ee0163b6 | ||
|
|
5165e95d82 | ||
|
|
6e0f48e99b | ||
|
|
adc329a7e7 | ||
|
|
c199d00300 | ||
|
|
38e79bb7db | ||
|
|
23050075b7 | ||
|
|
b2475e4d1d | ||
|
|
8e7e099294 | ||
|
|
dcb7143359 | ||
|
|
0ce24929dc | ||
|
|
9930288d1f | ||
|
|
ae8b1f840f | ||
|
|
15aa67ebb9 | ||
|
|
1de8d93b4b | ||
|
|
6b51b7dc58 | ||
|
|
d16fa4531c | ||
|
|
44eb8acaee | ||
|
|
2fca7e3719 | ||
|
|
62b408a2b3 | ||
|
|
465d28a1cd | ||
|
|
dcf3a722a7 | ||
|
|
6995388a29 | ||
|
|
4039272e75 | ||
|
|
c8f5be7e4f | ||
|
|
4a0e89730d | ||
|
|
bc238778ad | ||
|
|
9d37125677 | ||
|
|
d87c3293c9 | ||
|
|
789de1963a | ||
|
|
ba95780813 | ||
|
|
4508002947 | ||
|
|
2a56830426 | ||
|
|
6cf859fd9e | ||
|
|
91da088da4 | ||
|
|
076cc035d3 | ||
|
|
92992df7df | ||
|
|
13d131c2e9 | ||
|
|
8ea773aa24 | ||
|
|
0d24995c3e | ||
|
|
1e9f2e6282 | ||
|
|
73245e875d | ||
|
|
a329ae0ad1 | ||
|
|
652ff0060c | ||
|
|
d3476c0446 | ||
|
|
a0209eb433 | ||
|
|
e1ef28fdcc | ||
|
|
2e26193c4a | ||
|
|
42efc05bd0 | ||
|
|
809186a2e7 | ||
|
|
927e8e195c | ||
|
|
33f6271d13 | ||
|
|
2c1476f151 | ||
|
|
97ee4630b0 | ||
|
|
02b29f98b6 | ||
|
|
fdc33a223f | ||
|
|
7beafcac92 | ||
|
|
5563f30bf2 | ||
|
|
9d8ebf8ef3 | ||
|
|
a0ad238952 | ||
|
|
18b771e620 | ||
|
|
461a1f1b70 | ||
|
|
a066495c5f | ||
|
|
506b85770b | ||
|
|
bd8c4188d0 | ||
|
|
c78e8c54b8 | ||
|
|
c18cc964a8 | ||
|
|
8ee098eef1 | ||
|
|
a7bd2d3668 | ||
|
|
ca7c8a2f11 | ||
|
|
146375e320 | ||
|
|
992fb9093c | ||
|
|
99ccfcfbd4 | ||
|
|
420304fd9d | ||
|
|
81cb89e599 | ||
|
|
ee8b27837a | ||
|
|
a645bbe0ba | ||
|
|
4c7db0147e | ||
|
|
c1b2346e22 | ||
|
|
92a4150d6d | ||
|
|
829619cca7 | ||
|
|
408495b07a | ||
|
|
02f1a0da82 | ||
|
|
6d8ade073a | ||
|
|
1c946f7bb2 | ||
|
|
5724c3a09c | ||
|
|
d55464f3ab | ||
|
|
73a5a10c6d | ||
|
|
112420cb5b | ||
|
|
31823dbd53 |
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
39
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
|
||||
|
||||
## Content
|
||||
|
||||
<!-- Describe shortly what has been changed -->
|
||||
|
||||
## Motivation and context
|
||||
|
||||
<!-- Provide link to the corresponding issue if applicable or explain the context -->
|
||||
|
||||
## Screenshots / GIFs
|
||||
|
||||
<!--
|
||||
|
||||
You can use a table like this to show screenshots comparison.
|
||||
Uncomment this markdown table below and edit the last line `|||`:
|
||||
|copy screenshot of before here|copy screenshot of after here|
|
||||
|
||||
|Before|After|
|
||||
|-|-|
|
||||
|||
|
||||
-->
|
||||
|
||||
## Tests
|
||||
|
||||
<!-- Explain how you tested your development -->
|
||||
|
||||
- Step 1
|
||||
- Step 2
|
||||
- Step ...
|
||||
-
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md).
|
||||
- [ ] Pull request includes screenshots or videos if containing UI changes
|
||||
- [ ] Tests written for new code (and old code if feasible).
|
||||
- [ ] Linter and other CI checks pass.
|
||||
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call)
|
||||
55
.github/workflows/build-and-publish-docker.yaml
vendored
55
.github/workflows/build-and-publish-docker.yaml
vendored
@@ -20,10 +20,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required to upload release asset
|
||||
packages: write
|
||||
packages: write # needed for publishing packages to GHCR
|
||||
id-token: write # needed for login into tailscale with GitHub OIDC Token
|
||||
steps:
|
||||
- name: Check it out
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -34,26 +37,64 @@ jobs:
|
||||
path: dist
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Connect to Tailscale
|
||||
uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
audience: ${{ secrets.TS_AUDIENCE }}
|
||||
tags: tag:github-actions
|
||||
|
||||
- name: Compute vault jwt role name
|
||||
id: vault-jwt-role
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get team registry token
|
||||
id: import-secrets
|
||||
uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
url: https://vault.infra.ci.i.element.dev
|
||||
role: ${{ steps.vault-jwt-role.outputs.role_name }}
|
||||
path: service-management/github-actions
|
||||
jwtGithubAudience: https://vault.infra.ci.i.element.dev
|
||||
method: jwt
|
||||
secrets: |
|
||||
services/voip-repositories/secret/data/oci.element.io username | OCI_USERNAME ;
|
||||
services/voip-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
|
||||
|
||||
- name: Login to oci.element.io Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: oci-push.vpn.infra.element.io
|
||||
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
|
||||
password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: ${{ inputs.docker_tags}}
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
oci-push.vpn.infra.element.io/element-call
|
||||
tags: ${{ inputs.docker_tags }}
|
||||
labels: |
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
4
.github/workflows/build-element-call.yaml
vendored
4
.github/workflows/build-element-call.yaml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
type: string
|
||||
package:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
description: The package type to be built. Must be one of 'full' or 'embedded'
|
||||
description: The package type to be built. Must be one of 'full', 'embedded', or 'sdk'
|
||||
required: true
|
||||
build_mode:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
|
||||
16
.github/workflows/build.yaml
vendored
16
.github/workflows/build.yaml
vendored
@@ -49,7 +49,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.run_id }}
|
||||
docker_tags: |
|
||||
@@ -69,3 +71,17 @@ jobs:
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build_sdk_element_call:
|
||||
# Use the embedded package vite build
|
||||
uses: ./.github/workflows/build-element-call.yaml
|
||||
with:
|
||||
package: sdk
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
20
.github/workflows/deploy-to-netlify.yaml
vendored
20
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -14,6 +14,10 @@ on:
|
||||
deployment_ref:
|
||||
required: true
|
||||
type: string
|
||||
package:
|
||||
required: true
|
||||
type: string
|
||||
description: Which package to deploy - 'full', 'embedded', or 'sdk'
|
||||
artifact_run_id:
|
||||
required: false
|
||||
type: string
|
||||
@@ -50,7 +54,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
run-id: ${{ inputs.artifact_run_id }}
|
||||
name: build-output-full
|
||||
name: build-output-${{ inputs.package }}
|
||||
path: webapp
|
||||
|
||||
- name: Add redirects file
|
||||
@@ -58,15 +62,23 @@ jobs:
|
||||
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
|
||||
|
||||
- name: Add config file
|
||||
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
|
||||
|
||||
run: |
|
||||
if [ "${INPUTS_PACKAGE}" = "full" ]; then
|
||||
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json
|
||||
else
|
||||
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview_sdk.json" > webapp/config.json
|
||||
fi
|
||||
env:
|
||||
INPUTS_PACKAGE: ${{ inputs.package }}
|
||||
INPUTS_PR_HEAD_FULL_NAME: ${{ inputs.pr_head_full_name }}
|
||||
INPUTS_PR_HEAD_REF: ${{ inputs.pr_head_ref }}
|
||||
- name: ☁️ Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
|
||||
with:
|
||||
publish-dir: webapp
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
alias: pr${{ inputs.pr_number }}
|
||||
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@@ -8,6 +8,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
|
||||
22
.github/workflows/pr-deploy.yaml
vendored
22
.github/workflows/pr-deploy.yaml
vendored
@@ -20,7 +20,7 @@ jobs:
|
||||
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
|
||||
branch: ${{ github.event.workflow_run.head_branch }}
|
||||
|
||||
netlify:
|
||||
netlify-full:
|
||||
needs: prdetails
|
||||
permissions:
|
||||
deployments: write
|
||||
@@ -31,6 +31,24 @@ jobs:
|
||||
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||
package: full
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||
|
||||
netlify-sdk:
|
||||
needs: prdetails
|
||||
permissions:
|
||||
deployments: write
|
||||
uses: ./.github/workflows/deploy-to-netlify.yaml
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
pr_number: ${{ needs.prdetails.outputs.pr_number }}
|
||||
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
|
||||
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
|
||||
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
|
||||
package: sdk
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
@@ -42,7 +60,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
|
||||
71
.github/workflows/publish-embedded-packages.yaml
vendored
71
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -71,7 +71,9 @@ jobs:
|
||||
contents: write # required to upload release asset
|
||||
steps:
|
||||
- name: Determine filename
|
||||
run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
run: echo "FILENAME_PREFIX=element-call-embedded-${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
@@ -80,9 +82,9 @@ jobs:
|
||||
name: build-output-embedded
|
||||
path: ${{ env.FILENAME_PREFIX}}
|
||||
- name: Create Tarball
|
||||
run: tar --numeric-owner -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
|
||||
run: tar --numeric-owner -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
|
||||
- name: Create Checksum
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
@@ -104,6 +106,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -123,13 +127,16 @@ jobs:
|
||||
- name: Publish npm
|
||||
working-directory: embedded/web
|
||||
run: |
|
||||
npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version
|
||||
npm version ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION} --no-git-tag-version
|
||||
echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV"
|
||||
npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
npm publish --provenance --access public --tag ${NEEDS_VERSIONING_OUTPUTS_TAG} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish_android:
|
||||
needs: [build_element_call, versioning]
|
||||
@@ -143,6 +150,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -153,7 +162,7 @@ jobs:
|
||||
path: embedded/android/lib/src/main/assets/element-call
|
||||
|
||||
- name: ☕️ Setup Java
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
|
||||
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -161,16 +170,19 @@ jobs:
|
||||
- name: Get artifact version
|
||||
# Anything that is not a final release will be tagged as a snapshot
|
||||
run: |
|
||||
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
if [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "latest" ]]; then
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
elif [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "rc" ]]; then
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
|
||||
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}-SNAPSHOT" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
|
||||
- name: Set version string
|
||||
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
|
||||
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
|
||||
|
||||
- name: Publish AAR
|
||||
working-directory: embedded/android
|
||||
@@ -184,7 +196,7 @@ jobs:
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
publish_ios:
|
||||
needs: [build_element_call, versioning]
|
||||
@@ -200,6 +212,7 @@ jobs:
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
path: element-call
|
||||
persist-credentials: false
|
||||
|
||||
- name: 📥 Download built element-call artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
@@ -215,15 +228,18 @@ jobs:
|
||||
repository: element-hq/element-call-swift
|
||||
path: element-call-swift
|
||||
token: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Copy files
|
||||
run: rsync -a --delete --exclude .git element-call/embedded/ios/ element-call-swift
|
||||
|
||||
- name: Get artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
|
||||
run: echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
|
||||
|
||||
- name: Set version string
|
||||
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
|
||||
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
|
||||
|
||||
- name: Test build
|
||||
working-directory: element-call-swift
|
||||
@@ -235,17 +251,22 @@ jobs:
|
||||
git config --global user.email "ci@element.io"
|
||||
git config --global user.name "Element CI"
|
||||
git add -A
|
||||
git commit -am "Release ${{ needs.versioning.outputs.PREFIXED_VERSION }}"
|
||||
git tag -a ${{ env.ARTIFACT_VERSION }} -m "${{ github.event.release.html_url }}"
|
||||
git commit -am "Release ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION}"
|
||||
git tag -a ${ARTIFACT_VERSION} -m "${GITHUB_EVENT_RELEASE_HTML_URL}"
|
||||
env:
|
||||
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
|
||||
GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }}
|
||||
|
||||
- name: Push
|
||||
working-directory: element-call-swift
|
||||
run: |
|
||||
git push --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
|
||||
env:
|
||||
SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }}
|
||||
|
||||
- id: artifact_version
|
||||
name: Output artifact version
|
||||
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
|
||||
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release_notes:
|
||||
needs: [versioning, publish_npm, publish_android, publish_ios]
|
||||
@@ -257,9 +278,13 @@ jobs:
|
||||
steps:
|
||||
- name: Log versions
|
||||
run: |
|
||||
echo "NPM: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}"
|
||||
echo "Android: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}"
|
||||
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
|
||||
echo "NPM: ${NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION}"
|
||||
echo "Android: ${NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION}"
|
||||
echo "iOS: ${NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION}"
|
||||
env:
|
||||
NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}
|
||||
NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}
|
||||
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
|
||||
- name: Add release notes
|
||||
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
|
||||
6
.github/workflows/publish.yaml
vendored
6
.github/workflows/publish.yaml
vendored
@@ -38,9 +38,9 @@ jobs:
|
||||
name: build-output-full
|
||||
path: ${{ env.FILENAME_PREFIX }}
|
||||
- name: Create Tarball
|
||||
run: tar --numeric-owner --transform "s/dist/${{ env.FILENAME_PREFIX }}/" -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
|
||||
run: tar --numeric-owner --transform "s/dist/${FILENAME_PREFIX}/" -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
|
||||
- name: Create Checksum
|
||||
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
|
||||
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
@@ -55,7 +55,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/build-and-publish-docker.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
|
||||
docker_tags: |
|
||||
|
||||
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -10,6 +10,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Yarn cache
|
||||
@@ -22,7 +24,7 @@ jobs:
|
||||
- name: Vitest
|
||||
run: "yarn run test:coverage"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
@@ -34,6 +36,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
|
||||
4
.github/workflows/translations-download.yaml
vendored
4
.github/workflows/translations-download.yaml
vendored
@@ -14,6 +14,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -42,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||
with:
|
||||
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
branch: actions/localazy-download
|
||||
|
||||
2
.github/workflows/translations-upload.yaml
vendored
2
.github/workflows/translations-upload.yaml
vendored
@@ -15,6 +15,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout the code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Upload
|
||||
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1
|
||||
|
||||
23
.github/workflows/zizmor.yml
vendored
Normal file
23
.github/workflows/zizmor.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: GitHub Actions Security Analysis with zizmor 🌈
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["livekit", "full-mesh"]
|
||||
pull_request: {}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: Run zizmor 🌈
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
|
||||
16
config/config_netlify_preview_sdk.json
Normal file
16
config/config_netlify_preview_sdk.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"default_server_config": {
|
||||
"m.homeserver": {
|
||||
"base_url": "https://call-unstable.ems.host",
|
||||
"server_name": "call-unstable.ems.host"
|
||||
}
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.9.4
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit-1:
|
||||
image: livekit/livekit-server:v1.9.4
|
||||
image: livekit/livekit-server:v1.9.11
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu-1
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
|
||||
synapse:
|
||||
hostname: homeserver
|
||||
image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -106,7 +106,7 @@ services:
|
||||
|
||||
synapse-1:
|
||||
hostname: homeserver-1
|
||||
image: ghcr.io/element-hq/synapse:pr-18968-dcb7678281bc02d4551043a6338fe5b7e6aa47ce
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
|
||||
@@ -46,32 +46,32 @@ possible to support encryption.
|
||||
|
||||
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
|
||||
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
|
||||
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
|
||||
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
|
||||
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
|
||||
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
|
||||
| `displayName` | | No | No | Display name used for auto-registration. |
|
||||
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
|
||||
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
|
||||
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
|
||||
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
|
||||
| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
|
||||
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
|
||||
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
|
||||
| `displayName` | | No | No | Display name used for auto-registration. |
|
||||
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
|
||||
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
|
||||
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
|
||||
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
|
||||
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
|
||||
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
|
||||
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
|
||||
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
|
||||
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
|
||||
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
|
||||
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
|
||||
|
||||
### Widget-only parameters
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||
|
||||
[versions]
|
||||
android_gradle_plugin = "8.13.1"
|
||||
android_gradle_plugin = "8.13.2"
|
||||
|
||||
[libraries]
|
||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.35.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
6
knip.ts
6
knip.ts
@@ -34,6 +34,12 @@ export default {
|
||||
// then Knip will flag it as a false positive
|
||||
// https://github.com/webpro-nl/knip/issues/766
|
||||
"@vector-im/compound-web",
|
||||
// Yarn plugins are allowed to depend on packages provided by the Yarn
|
||||
// runtime. These shouldn't be listed in package.json, because plugins
|
||||
// should work before Yarn even installs dependencies for the first time.
|
||||
// https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like
|
||||
"@yarnpkg/core",
|
||||
"@yarnpkg/parsers",
|
||||
"matrix-widget-api",
|
||||
],
|
||||
ignoreExportsUsedInFile: true,
|
||||
|
||||
@@ -250,11 +250,11 @@
|
||||
"video_tile": {
|
||||
"always_show": "Always show",
|
||||
"camera_starting": "Video loading...",
|
||||
"change_fit_contain": "Fit to frame",
|
||||
"collapse": "Collapse",
|
||||
"expand": "Expand",
|
||||
"mute_for_me": "Mute for me",
|
||||
"muted_for_me": "Muted for me",
|
||||
"screen_share_volume": "Screen share volume",
|
||||
"volume": "Volume",
|
||||
"waiting_for_media": "Waiting for media..."
|
||||
}
|
||||
|
||||
13
package.json
13
package.json
@@ -13,8 +13,9 @@
|
||||
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "yarn build:embedded",
|
||||
"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",
|
||||
"build:sdk": "yarn build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "yarn build:sdk",
|
||||
"serve": "vite preview",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
@@ -42,12 +43,12 @@
|
||||
"@codecov/vite-plugin": "^1.3.0",
|
||||
"@fontsource/inconsolata": "^5.1.0",
|
||||
"@fontsource/inter": "^5.1.0",
|
||||
"@formatjs/intl-durationformat": "^0.9.0",
|
||||
"@formatjs/intl-durationformat": "^0.10.0",
|
||||
"@formatjs/intl-segmenter": "^11.7.3",
|
||||
"@livekit/components-core": "^0.12.0",
|
||||
"@livekit/components-react": "^2.0.0",
|
||||
"@livekit/protocol": "^1.42.2",
|
||||
"@livekit/track-processors": "^0.6.0 || ^0.7.1",
|
||||
"@livekit/track-processors": "^0.7.1",
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
@@ -100,11 +101,11 @@
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-parser": "^9.1.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"knip": "^5.27.2",
|
||||
"knip": "^5.86.0",
|
||||
"livekit-client": "^2.13.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -117,7 +118,7 @@
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react-i18next": "^16.0.0 <16.1.0",
|
||||
"react-i18next": "^16.0.0 <16.6.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -60,7 +60,7 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = frame.getByTestId("incall_videomute");
|
||||
await expect(videoButton).toBeVisible();
|
||||
// video should be off by default in a voice call
|
||||
// video should be on
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
}
|
||||
|
||||
|
||||
68
playwright/widget/pip-call-button-interaction.test.ts
Normal file
68
playwright/widget/pip-call-button-interaction.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Copyright 2026 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 { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
|
||||
const callRoom = "CallRoom";
|
||||
await TestHelpers.createRoom("CallRoom", valere.page);
|
||||
|
||||
await TestHelpers.createRoom("OtherRoom", valere.page);
|
||||
|
||||
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
|
||||
|
||||
// Start the call as Valere
|
||||
await TestHelpers.startCallInCurrentRoom(valere.page, false);
|
||||
await expect(
|
||||
valere.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
// wait a bit so that the PIP has rendered
|
||||
await valere.page.waitForTimeout(600);
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
|
||||
|
||||
// We should see the PIP overlay
|
||||
const iFrame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
{
|
||||
// Check for a bug where the video had the wrong fit in PIP
|
||||
const hangupBtn = iFrame.getByRole("button", { name: "End call" });
|
||||
const audioBtn = iFrame.getByTestId("incall_mute");
|
||||
const videoBtn = iFrame.getByTestId("incall_videomute");
|
||||
await expect(hangupBtn).toBeVisible();
|
||||
await expect(audioBtn).toBeVisible();
|
||||
await expect(videoBtn).toBeVisible();
|
||||
await expect(audioBtn).toHaveAttribute("aria-label", /^Mute microphone$/);
|
||||
await expect(videoBtn).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
await videoBtn.click();
|
||||
await audioBtn.click();
|
||||
|
||||
// stop hovering on any of the buttons
|
||||
await iFrame.getByTestId("videoTile").hover();
|
||||
|
||||
await expect(audioBtn).toHaveAttribute("aria-label", /^Unmute microphone$/);
|
||||
await expect(videoBtn).toHaveAttribute("aria-label", /^Start video$/);
|
||||
}
|
||||
});
|
||||
74
playwright/widget/pip-call.test.ts
Normal file
74
playwright/widget/pip-call.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright 2026 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 { widgetTest } from "../fixtures/widget-user.ts";
|
||||
import { HOST1, TestHelpers } from "./test-helpers.ts";
|
||||
|
||||
widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
const valere = await addUser("Valere", HOST1);
|
||||
const timo = await addUser("Timo", HOST1);
|
||||
|
||||
const callRoom = "TeamRoom";
|
||||
await TestHelpers.createRoom(callRoom, valere.page, [timo.mxId]);
|
||||
|
||||
await TestHelpers.createRoom("DoubleTask", valere.page);
|
||||
|
||||
await TestHelpers.acceptRoomInvite(callRoom, timo.page);
|
||||
|
||||
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
|
||||
|
||||
// Start the call as Valere
|
||||
await TestHelpers.startCallInCurrentRoom(valere.page, false);
|
||||
await expect(
|
||||
valere.page.locator('iframe[title="Element Call"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await TestHelpers.joinCallFromLobby(valere.page);
|
||||
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
{
|
||||
const frame = timo.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
const videoButton = frame.getByTestId("incall_videomute");
|
||||
await expect(videoButton).toBeVisible();
|
||||
// check that the video is on
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
}
|
||||
|
||||
// Switch to the other room, the call should go to PIP
|
||||
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
|
||||
|
||||
// We should see the PIP overlay
|
||||
await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible();
|
||||
|
||||
{
|
||||
// wait a bit so that the PIP has rendered the video
|
||||
await valere.page.waitForTimeout(600);
|
||||
|
||||
// Check for a bug where the video had the wrong fit in PIP
|
||||
const frame = valere.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const videoElements = await frame.locator("video").all();
|
||||
expect(videoElements.length).toBe(1);
|
||||
|
||||
const pipVideo = videoElements[0];
|
||||
await expect(pipVideo).toHaveCSS("object-fit", "cover");
|
||||
}
|
||||
});
|
||||
@@ -276,4 +276,16 @@ export class TestHelpers {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
* @param roomName - The name of the room to switch to
|
||||
*/
|
||||
public static async switchToRoomNamed(
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# SDK mode
|
||||
# SDK mode (EXPERIMENTAL)
|
||||
|
||||
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
|
||||
|
||||
@@ -21,7 +21,7 @@ in the repository root.
|
||||
|
||||
It will create a `dist` folder containing the compiled js file.
|
||||
|
||||
This file needs to be hosted. Locally (via `npx serve -l 1234 --cors`) or on a remote server.
|
||||
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
|
||||
|
||||
Now you just need to add the widget to element web via:
|
||||
|
||||
|
||||
@@ -12,15 +12,12 @@ Please see LICENSE in the repository root for full details.
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { scan } from "rxjs";
|
||||
|
||||
import { widget as _widget } from "../src/widget";
|
||||
import { type WidgetHelpers } 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 => {
|
||||
export const tryMakeSticky = (widget: WidgetHelpers): void => {
|
||||
logger.info("try making sticky MatrixRTCSdk");
|
||||
void widget.api
|
||||
.setAlwaysOnScreen(true)
|
||||
|
||||
108
sdk/main.ts
108
sdk/main.ts
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* EXPERIMENTAL
|
||||
*
|
||||
* 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
|
||||
@@ -30,8 +32,8 @@ import {
|
||||
} from "rxjs";
|
||||
import {
|
||||
type CallMembership,
|
||||
MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
MatrixRTCSessionManager,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
type Room as LivekitRoom,
|
||||
@@ -50,14 +52,12 @@ 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 } from "./helper";
|
||||
import {
|
||||
currentAndPrev,
|
||||
logger,
|
||||
TEXT_LK_TOPIC,
|
||||
tryMakeSticky,
|
||||
widget,
|
||||
} from "./helper";
|
||||
import { ElementWidgetActions, initializeWidget } from "../src/widget";
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
initializeWidget,
|
||||
} from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
@@ -68,7 +68,13 @@ interface MatrixRTCSdk {
|
||||
join: () => void;
|
||||
/** @throws on leave errors */
|
||||
leave: () => void;
|
||||
data$: Observable<{ sender: string; data: string }>;
|
||||
/**
|
||||
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
|
||||
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
|
||||
* A new sdk needs to be created via createMatrixRTCSdk.
|
||||
*/
|
||||
stop: () => void;
|
||||
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
|
||||
/**
|
||||
* flattened list of members
|
||||
*/
|
||||
@@ -79,32 +85,54 @@ interface MatrixRTCSdk {
|
||||
participant: LocalParticipant | RemoteParticipant | null;
|
||||
}[]
|
||||
>;
|
||||
/**
|
||||
* flattened local members
|
||||
*/
|
||||
localMember$: Behavior<{
|
||||
connection: Connection | null;
|
||||
membership: CallMembership;
|
||||
participant: LocalParticipant | null;
|
||||
} | null>;
|
||||
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
|
||||
connected$: Behavior<boolean>;
|
||||
sendData?: (data: unknown) => Promise<void>;
|
||||
sendRoomMessage?: (message: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export async function createMatrixRTCSdk(
|
||||
application: string = "m.call",
|
||||
id: string = "",
|
||||
sticky: boolean = false,
|
||||
): Promise<MatrixRTCSdk> {
|
||||
initializeWidget();
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
initializeWidget(application, true);
|
||||
const widget = _widget;
|
||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
const client = await widget.client;
|
||||
logger.info("client created");
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// url params
|
||||
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");
|
||||
|
||||
// rtc session
|
||||
const slot = { application, id };
|
||||
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
|
||||
rtcSessionManager.start();
|
||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||
|
||||
// media devices
|
||||
const mediaDevices = new MediaDevices(scope);
|
||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
||||
audioEnabled: true,
|
||||
videoEnabled: true,
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
});
|
||||
const slot = { application, id };
|
||||
const rtcSession = new MatrixRTCSession(client, room, slot);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
scope,
|
||||
rtcSession,
|
||||
@@ -117,8 +145,9 @@ export async function createMatrixRTCSdk(
|
||||
constant({ supported: false, processor: undefined }),
|
||||
);
|
||||
logger.info("CallViewModelCreated");
|
||||
|
||||
// create data listener
|
||||
const data$ = new Subject<{ sender: string; data: string }>();
|
||||
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
|
||||
|
||||
const lkTextStreamHandlerFunction = async (
|
||||
reader: TextStreamReader,
|
||||
@@ -140,7 +169,7 @@ export async function createMatrixRTCSdk(
|
||||
if (participants && participants.includes(participantInfo.identity)) {
|
||||
const text = await reader.readAll();
|
||||
logger.info(`Received text: ${text}`);
|
||||
data$.next({ sender: participantInfo.identity, data: text });
|
||||
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
|
||||
} else {
|
||||
logger.warn(
|
||||
"Received text from unknown participant",
|
||||
@@ -230,6 +259,16 @@ export async function createMatrixRTCSdk(
|
||||
}
|
||||
};
|
||||
|
||||
const sendRoomMessage = async (message: string): Promise<void> => {
|
||||
const messageString = JSON.stringify(message);
|
||||
logger.info("try sending to room: ", messageString);
|
||||
try {
|
||||
await client.sendTextMessage(room.roomId, message);
|
||||
} catch (e) {
|
||||
logger.error("failed sending to room: ", messageString, e);
|
||||
}
|
||||
};
|
||||
|
||||
// after hangup gets called
|
||||
const leaveSubs = callViewModel.leave$.subscribe(() => {
|
||||
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
|
||||
@@ -257,9 +296,6 @@ export async function createMatrixRTCSdk(
|
||||
|
||||
// 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");
|
||||
@@ -267,15 +303,40 @@ export async function createMatrixRTCSdk(
|
||||
return {
|
||||
join: (): void => {
|
||||
// first lets try making the widget sticky
|
||||
tryMakeSticky();
|
||||
if (sticky) tryMakeSticky(widget);
|
||||
callViewModel.join();
|
||||
},
|
||||
leave: (): void => {
|
||||
callViewModel.hangup();
|
||||
callViewModel.leave();
|
||||
},
|
||||
stop: (): void => {
|
||||
leaveSubs.unsubscribe();
|
||||
livekitRoomItemsSub.unsubscribe();
|
||||
scope.end();
|
||||
},
|
||||
data$,
|
||||
localMember$: scope.behavior(
|
||||
callViewModel.localMatrixLivekitMember$.pipe(
|
||||
tap((member) =>
|
||||
logger.info("localMatrixLivekitMember$ next: ", member),
|
||||
),
|
||||
switchMap((member) => {
|
||||
if (member === null) return of(null);
|
||||
return combineLatest([
|
||||
member.connection$,
|
||||
member.membership$,
|
||||
member.participant.value$,
|
||||
]).pipe(
|
||||
map(([connection, membership, participant]) => ({
|
||||
connection,
|
||||
membership,
|
||||
participant,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
tap((member) => logger.info("localMember$ next: ", member)),
|
||||
),
|
||||
),
|
||||
connected$: callViewModel.connected$,
|
||||
members$: scope.behavior(
|
||||
callViewModel.matrixLivekitMembers$.pipe(
|
||||
@@ -302,5 +363,6 @@ export async function createMatrixRTCSdk(
|
||||
[],
|
||||
),
|
||||
sendData,
|
||||
sendRoomMessage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -473,8 +473,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
homeserver: !isWidget ? parser.getParam("homeserver") : null,
|
||||
posthogApiHost: parser.getParam("posthogApiHost"),
|
||||
posthogApiKey: parser.getParam("posthogApiKey"),
|
||||
posthogUserId:
|
||||
parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
|
||||
posthogUserId: parser.getParam("posthogUserId"),
|
||||
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
|
||||
sentryDsn: parser.getParam("sentryDsn"),
|
||||
sentryEnvironment: parser.getParam("sentryEnvironment"),
|
||||
|
||||
@@ -23,6 +23,7 @@ import styles from "./Button.module.css";
|
||||
|
||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
@@ -47,6 +48,7 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
@@ -71,6 +73,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
@@ -94,7 +97,11 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
@@ -114,9 +121,10 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsButton: FC<ComponentPropsWithoutRef<"button">> = (
|
||||
props,
|
||||
) => {
|
||||
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
||||
@@ -166,6 +166,7 @@ export function ReactionPopupMenu({
|
||||
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
identifier: string;
|
||||
vm: CallViewModel;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export function ReactionToggleButton({
|
||||
|
||||
@@ -27,7 +27,13 @@ interface Props<M, R extends HTMLElement> {
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
|
||||
) => void
|
||||
> | null;
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetWidth: number;
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetHeight: number;
|
||||
model: M;
|
||||
Tile: ComponentType<TileProps<M, R>>;
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function getSFUConfigWithOpenID(
|
||||
} else {
|
||||
logger?.warn(
|
||||
`Failed fetching jwt with matrix 2.0 endpoint other issues ->`,
|
||||
`(not going to try with legacy endpoint if forceMatrix2Jwt is set to false (it is ${forceMatrix2Jwt}), we did not get a not supported error from the sfu)`,
|
||||
`(not going to try with legacy endpoint: forceOldJwtEndpoint is set to false, we did not get a not supported error from the sfu)`,
|
||||
e,
|
||||
);
|
||||
// Make this throw a hard error in case we force the matrix2.0 endpoint.
|
||||
|
||||
@@ -65,6 +65,7 @@ Please see LICENSE in the repository root for full details.
|
||||
.footer.overlay.hidden {
|
||||
display: grid;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.footer.overlay:has(:focus-visible) {
|
||||
@@ -107,22 +108,9 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 370px) {
|
||||
.shareScreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-height: 400px) {
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.invite,
|
||||
.raiseHand {
|
||||
display: none;
|
||||
@media (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,9 +120,27 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x);
|
||||
@media (max-width: 370px) {
|
||||
.shareScreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* PIP custom css */
|
||||
@media (max-height: 400px) {
|
||||
.shareScreen {
|
||||
display: flex;
|
||||
}
|
||||
.footer {
|
||||
padding-block-start: var(--cpd-space-3x);
|
||||
padding-block-end: var(--cpd-space-2x);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.invite,
|
||||
.raiseHand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
type TouchEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -110,8 +110,6 @@ import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
|
||||
const maxTapDurationMs = 400;
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState"
|
||||
@@ -334,40 +332,20 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
) : null;
|
||||
}, [ringOverlay]);
|
||||
|
||||
// Ideally we could detect taps by listening for click events and checking
|
||||
// that the pointerType of the event is "touch", but this isn't yet supported
|
||||
// in Safari: https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#browser_compatibility
|
||||
// Instead we have to watch for sufficiently fast touch events.
|
||||
const touchStart = useRef<number | null>(null);
|
||||
const onTouchStart = useCallback(() => (touchStart.current = Date.now()), []);
|
||||
const onTouchEnd = useCallback(() => {
|
||||
const start = touchStart.current;
|
||||
if (start !== null && Date.now() - start <= maxTapDurationMs)
|
||||
vm.tapScreen();
|
||||
touchStart.current = null;
|
||||
}, [vm]);
|
||||
const onTouchCancel = useCallback(() => (touchStart.current = null), []);
|
||||
|
||||
// We also need to tell the footer controls to prevent touch events from
|
||||
// bubbling up, or else the footer will be dismissed before a click/change
|
||||
// event can be registered on the control
|
||||
const onControlsTouchEnd = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
// Somehow applying pointer-events: none to the controls when the footer
|
||||
// is hidden is not enough to stop clicks from happening as the footer
|
||||
// becomes visible, so we check manually whether the footer is shown
|
||||
if (showFooter) {
|
||||
e.stopPropagation();
|
||||
vm.tapControls();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
}
|
||||
const onViewClick = useCallback(
|
||||
(e: ReactMouseEvent) => {
|
||||
if (
|
||||
(e.nativeEvent as PointerEvent).pointerType === "touch" &&
|
||||
// If an interactive element was tapped, don't count this as a tap on the screen
|
||||
(e.target as Element).closest?.("button, input") === null
|
||||
)
|
||||
vm.tapScreen();
|
||||
},
|
||||
[vm, showFooter],
|
||||
[vm],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(e: PointerEvent) => {
|
||||
(e: ReactPointerEvent) => {
|
||||
if (e.pointerType === "mouse") vm.hoverScreen();
|
||||
},
|
||||
[vm],
|
||||
@@ -606,8 +584,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
vm={layout.spotlight}
|
||||
expanded
|
||||
onToggleExpanded={null}
|
||||
targetWidth={gridBounds.height}
|
||||
targetHeight={gridBounds.width}
|
||||
targetWidth={gridBounds.width}
|
||||
targetHeight={gridBounds.height}
|
||||
showIndicators={false}
|
||||
focusable={!contentObscured}
|
||||
aria-hidden={contentObscured}
|
||||
@@ -662,20 +640,21 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
const buttons: JSX.Element[] = [];
|
||||
|
||||
const buttonSize = layout.type === "pip" ? "sm" : "lg";
|
||||
buttons.push(
|
||||
<MicButton
|
||||
size={buttonSize}
|
||||
key="audio"
|
||||
muted={!audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
disabled={toggleAudio === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
muted={!videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
disabled={toggleVideo === null}
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
@@ -683,11 +662,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (vm.toggleScreenSharing !== null) {
|
||||
buttons.push(
|
||||
<ShareScreenButton
|
||||
size={buttonSize}
|
||||
key="share_screen"
|
||||
className={styles.shareScreen}
|
||||
enabled={sharingScreen}
|
||||
onClick={vm.toggleScreenSharing}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_screenshare"
|
||||
/>,
|
||||
);
|
||||
@@ -695,30 +674,30 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
if (supportsReactions) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
vm={vm}
|
||||
key="raise_hand"
|
||||
className={styles.raiseHand}
|
||||
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (layout.type !== "pip")
|
||||
buttons.push(
|
||||
<SettingsButton
|
||||
size={buttonSize}
|
||||
key="settings"
|
||||
onClick={openSettings}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>,
|
||||
);
|
||||
|
||||
buttons.push(
|
||||
<EndCallButton
|
||||
size={buttonSize}
|
||||
key="end_call"
|
||||
onClick={function (): void {
|
||||
vm.hangup();
|
||||
}}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_leave"
|
||||
/>,
|
||||
);
|
||||
@@ -751,7 +730,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
className={styles.layout}
|
||||
layout={gridMode}
|
||||
setLayout={setGridMode}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -760,12 +738,13 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const allConnections = useBehavior(vm.allConnections$);
|
||||
|
||||
return (
|
||||
// The onClick handler here exists to control the visibility of the footer,
|
||||
// and the footer is also viewable by moving focus into it, so this is fine.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
ref={containerRef}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchCancel}
|
||||
onClick={onViewClick}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerOut={onPointerOut}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ChangeEvent, type FC, type TouchEvent, useCallback } from "react";
|
||||
import { type ChangeEvent, type FC, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
@@ -22,15 +22,9 @@ interface Props {
|
||||
layout: Layout;
|
||||
setLayout: (layout: Layout) => void;
|
||||
className?: string;
|
||||
onTouchEnd?: (e: TouchEvent) => void;
|
||||
}
|
||||
|
||||
export const LayoutToggle: FC<Props> = ({
|
||||
layout,
|
||||
setLayout,
|
||||
className,
|
||||
onTouchEnd,
|
||||
}) => {
|
||||
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onChange = useCallback(
|
||||
@@ -47,7 +41,6 @@ export const LayoutToggle: FC<Props> = ({
|
||||
value="spotlight"
|
||||
checked={layout === "spotlight"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</Tooltip>
|
||||
<SpotlightIcon aria-hidden width={24} height={24} />
|
||||
@@ -58,7 +51,6 @@ export const LayoutToggle: FC<Props> = ({
|
||||
value="grid"
|
||||
checked={layout === "grid"}
|
||||
onChange={onChange}
|
||||
onTouchEnd={onTouchEnd}
|
||||
/>
|
||||
</Tooltip>
|
||||
<GridIcon aria-hidden width={24} height={24} />
|
||||
|
||||
34
src/settings/rageshake.test.ts
Normal file
34
src/settings/rageshake.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Copyright 2026 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, it } from "vitest";
|
||||
|
||||
import { init as initRageshake } from "./rageshake";
|
||||
|
||||
it("Logger should not crash if JSON.stringify fails", async () => {
|
||||
// JSON.stringify can throw. We want to make sure that the logger can handle this gracefully.
|
||||
await initRageshake();
|
||||
|
||||
const bigIntObj = { n: 1n };
|
||||
const notStringifiable = {
|
||||
bigIntObj,
|
||||
};
|
||||
// @ts-expect-error - we want to create an object that cannot be stringified
|
||||
notStringifiable.foo = notStringifiable; // circular reference
|
||||
|
||||
// ensure this cannot be stringified
|
||||
expect(() => JSON.stringify(notStringifiable)).toThrow();
|
||||
|
||||
expect(() =>
|
||||
global.mx_rage_logger.log(
|
||||
1,
|
||||
"test",
|
||||
"This is a test message",
|
||||
notStringifiable,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
@@ -75,7 +75,14 @@ class ConsoleLogger extends EventEmitter {
|
||||
} else if (arg instanceof Error) {
|
||||
return arg.message + (arg.stack ? `\n${arg.stack}` : "");
|
||||
} else if (typeof arg === "object") {
|
||||
return JSON.stringify(arg, getCircularReplacer());
|
||||
try {
|
||||
return JSON.stringify(arg, getCircularReplacer());
|
||||
} catch {
|
||||
// Stringify can fail if the object has circular references or if
|
||||
// there is a bigInt.
|
||||
// Did happen even with our `getCircularReplacer`. In this case, just log
|
||||
return "<$ failed to serialize object $>";
|
||||
}
|
||||
} else {
|
||||
return arg;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ICallNotifyContent,
|
||||
type IRTCNotificationContent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
EventType,
|
||||
@@ -25,23 +21,23 @@ import {
|
||||
localRtcMember,
|
||||
} from "../../utils/test-fixtures";
|
||||
import {
|
||||
type CallNotificationWrapper,
|
||||
createCallNotificationLifecycle$,
|
||||
type Props as CallNotificationLifecycleProps,
|
||||
} from "./CallNotificationLifecycle";
|
||||
import { trackEpoch } from "../ObservableScope";
|
||||
|
||||
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
||||
function mockRingEvent(
|
||||
eventId: string,
|
||||
lifetimeMs: number | undefined,
|
||||
sender = local.userId,
|
||||
): { event_id: string } & IRTCNotificationContent {
|
||||
): CallNotificationWrapper {
|
||||
return {
|
||||
event_id: eventId,
|
||||
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
|
||||
notification_type: "ring",
|
||||
sender,
|
||||
} as unknown as { event_id: string } & IRTCNotificationContent;
|
||||
} as unknown as CallNotificationWrapper;
|
||||
}
|
||||
|
||||
describe("waitForCallPickup$", () => {
|
||||
@@ -54,7 +50,7 @@ describe("waitForCallPickup$", () => {
|
||||
behavior("a", { a: [] }).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif1", 30), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif1", 30),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -86,7 +82,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("5ms a", {
|
||||
a: [mockRingEvent("$notif2", 100), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif2", 100),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -115,7 +111,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("20ms a", {
|
||||
a: [mockRingEvent("$notif2", 50), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif2", 50),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -142,7 +138,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif2", undefined), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif2", undefined),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -171,7 +167,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$notif5", 30), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$notif5", 30),
|
||||
}),
|
||||
receivedDecline$: hot(""),
|
||||
options: {
|
||||
@@ -210,7 +206,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$decl1", 50), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$decl1", 50),
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
@@ -254,7 +250,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$decl", 20), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$decl", 20),
|
||||
}),
|
||||
receivedDecline$: hot("40ms d", {
|
||||
d: [
|
||||
@@ -305,7 +301,7 @@ describe("waitForCallPickup$", () => {
|
||||
}).pipe(trackEpoch()),
|
||||
),
|
||||
sentCallNotification$: hot("10ms a", {
|
||||
a: [mockRingEvent("$right", 50), mockLegacyRingEvent],
|
||||
a: mockRingEvent("$right", 50),
|
||||
}),
|
||||
receivedDecline$: hot("20ms d", {
|
||||
d: [
|
||||
|
||||
@@ -7,9 +7,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
combineLatest,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
|
||||
export type CallPickupState =
|
||||
| "unknown"
|
||||
@@ -46,9 +47,11 @@ export type CallPickupState =
|
||||
| "decline"
|
||||
| "success"
|
||||
| null;
|
||||
export type CallNotificationWrapper = Parameters<
|
||||
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
|
||||
>;
|
||||
|
||||
export type CallNotificationWrapper = {
|
||||
event_id: string;
|
||||
} & IRTCNotificationContent;
|
||||
|
||||
export function createSentCallNotification$(
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
@@ -80,6 +83,7 @@ export interface Props {
|
||||
options: { waitForCallPickup?: boolean; autoLeaveWhenOthersLeft?: boolean };
|
||||
localUser: { deviceId: string; userId: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns two observables:
|
||||
* `callPickupState$` The current call pickup state of the call.
|
||||
@@ -140,12 +144,12 @@ export function createCallNotificationLifecycle$({
|
||||
scope.behavior(
|
||||
sentCallNotification$.pipe(
|
||||
filter(
|
||||
(newAndLegacyEvents) =>
|
||||
(notificationEventArgs: CallNotificationWrapper | null) =>
|
||||
// only care about new events (legacy do not have decline pattern)
|
||||
newAndLegacyEvents?.[0].notification_type === "ring",
|
||||
notificationEventArgs?.notification_type === "ring",
|
||||
),
|
||||
map((e) => e as CallNotificationWrapper),
|
||||
switchMap(([notificationEvent]) => {
|
||||
switchMap((notificationEvent) => {
|
||||
const lifetimeMs = notificationEvent?.lifetime ?? 0;
|
||||
return concat(
|
||||
lifetimeMs === 0
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Status,
|
||||
type CallMembership,
|
||||
type IRTCNotificationContent,
|
||||
type ICallNotifyContent,
|
||||
MatrixRTCSessionEvent,
|
||||
type LivekitTransport,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -232,10 +231,6 @@ function mockRingEvent(
|
||||
} as unknown as { event_id: string } & IRTCNotificationContent;
|
||||
}
|
||||
|
||||
// The app doesn't really care about the content of these legacy events, we just
|
||||
// need a value to fill in for them when emitting notifications
|
||||
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
|
||||
|
||||
describe.each([
|
||||
[MatrixRTCMode.Legacy],
|
||||
[MatrixRTCMode.Compatibility],
|
||||
@@ -1109,7 +1104,6 @@ describe.each([
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif1", 30),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1151,7 +1145,6 @@ describe.each([
|
||||
rtcSession.emit(
|
||||
MatrixRTCSessionEvent.DidSendCallNotification,
|
||||
mockRingEvent("$notif2", 100),
|
||||
mockLegacyRingEvent,
|
||||
);
|
||||
},
|
||||
d: () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
@@ -51,15 +51,9 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
type MediaViewModel,
|
||||
type RemoteUserMediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "../MediaViewModel";
|
||||
import {
|
||||
accumulate,
|
||||
createToggle$,
|
||||
filterBehavior,
|
||||
generateItem,
|
||||
generateItems,
|
||||
pauseWhen,
|
||||
} from "../../utils/observable";
|
||||
@@ -69,7 +63,7 @@ import {
|
||||
playReactionsSound,
|
||||
showReactions,
|
||||
} from "../../settings/settings";
|
||||
import { isFirefox } from "../../Platform";
|
||||
import { isFirefox, platform } from "../../Platform";
|
||||
import { setPipEnabled$ } from "../../controls";
|
||||
import { TileStore } from "../TileStore";
|
||||
import { gridLikeLayout } from "../GridLikeLayout";
|
||||
@@ -91,8 +85,6 @@ import { type MuteStates } from "../MuteStates";
|
||||
import { getUrlParams } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import { UserMedia } from "../UserMedia.ts";
|
||||
import { ScreenShare } from "../ScreenShare.ts";
|
||||
import {
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
@@ -103,7 +95,7 @@ import {
|
||||
type SpotlightPortraitLayoutMedia,
|
||||
} from "../layout-types.ts";
|
||||
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
|
||||
import { type ObservableScope } from "../ObservableScope.ts";
|
||||
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
|
||||
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
|
||||
import {
|
||||
createLocalMembership$,
|
||||
@@ -143,6 +135,14 @@ import {
|
||||
import { Publisher } from "./localMember/Publisher.ts";
|
||||
import { type Connection } from "./remoteMembers/Connection.ts";
|
||||
import { createLayoutModeSwitch } from "./LayoutSwitch.ts";
|
||||
import {
|
||||
createWrappedUserMedia,
|
||||
type MediaItem,
|
||||
type WrappedUserMediaViewModel,
|
||||
} from "../media/MediaItem.ts";
|
||||
import { type ScreenShareViewModel } from "../media/ScreenShareViewModel.ts";
|
||||
import { type UserMediaViewModel } from "../media/UserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "../media/MediaViewModel.ts";
|
||||
|
||||
const logger = rootLogger.getChild("[CallViewModel]");
|
||||
//TODO
|
||||
@@ -192,7 +192,6 @@ interface LayoutScanState {
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
type MediaItem = UserMedia | ScreenShare;
|
||||
export type LivekitRoomItem = {
|
||||
livekitRoom: LivekitRoom;
|
||||
participants: string[];
|
||||
@@ -217,15 +216,23 @@ export interface CallViewModel {
|
||||
"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.
|
||||
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
|
||||
* - by ending the scope
|
||||
* - or calling requestDisconnect
|
||||
*
|
||||
* TODO: it seems more reasonable to add a leave() method (that calls requestDisconnect) that will then update leave$ and remove the hangup pattern
|
||||
*/
|
||||
leave$: Observable<"user" | AutoLeaveReason>;
|
||||
/** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */
|
||||
/** Call to initiate hangup. Use in conbination with reconnection state track the async hangup process. */
|
||||
hangup: () => void;
|
||||
|
||||
// joining
|
||||
join: () => void;
|
||||
|
||||
/**
|
||||
* calls requestDisconnect. The async leave state can than be observed via connected$
|
||||
*/
|
||||
leave: () => void;
|
||||
// screen sharing
|
||||
/**
|
||||
* Callback to toggle screen sharing. If null, screen sharing is not possible.
|
||||
@@ -274,7 +281,6 @@ export interface CallViewModel {
|
||||
allConnections$: Behavior<ConnectionManagerData>;
|
||||
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
||||
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
|
||||
userMedia$: Behavior<UserMedia[]>;
|
||||
/** use the layout instead, this is just for the sdk export. */
|
||||
matrixLivekitMembers$: Behavior<RemoteMatrixLivekitMember[]>;
|
||||
localMatrixLivekitMember$: Behavior<LocalMatrixLivekitMember | null>;
|
||||
@@ -325,10 +331,6 @@ export interface CallViewModel {
|
||||
gridMode$: Behavior<GridMode>;
|
||||
setGridMode: (value: GridMode) => void;
|
||||
|
||||
// media view models and layout
|
||||
grid$: Behavior<UserMediaViewModel[]>;
|
||||
spotlight$: Behavior<MediaViewModel[]>;
|
||||
pip$: Behavior<UserMediaViewModel | null>;
|
||||
/**
|
||||
* The layout of tiles in the call interface.
|
||||
*/
|
||||
@@ -436,38 +438,42 @@ export function createCallViewModel$(
|
||||
memberId: uuidv4(),
|
||||
};
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
delayId$: scope.behavior(
|
||||
(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MembershipManagerEvent.DelayIdChanged,
|
||||
// The type of reemitted event includes the original emitted as the second arg.
|
||||
) as Observable<[string | undefined, IMembershipManager]>
|
||||
).pipe(map(([delayId]) => delayId ?? null)),
|
||||
matrixRTCSession.delayId ?? null,
|
||||
),
|
||||
roomId: matrixRoom.roomId,
|
||||
forceJwtEndpoint$: scope.behavior(
|
||||
matrixRTCMode$.pipe(
|
||||
map((v) =>
|
||||
v === MatrixRTCMode.Matrix_2_0
|
||||
? JwtEndpointVersion.Matrix_2_0
|
||||
: JwtEndpointVersion.Legacy,
|
||||
),
|
||||
const localTransport$ = scope.behavior(
|
||||
matrixRTCMode$.pipe(
|
||||
generateItem(
|
||||
"CallViewModel localTransport$",
|
||||
// Re-create LocalTransport whenever the mode changes
|
||||
(mode) => ({ keys: [mode], data: undefined }),
|
||||
(scope, _data$, mode) =>
|
||||
createLocalTransport$({
|
||||
scope: scope,
|
||||
memberships$: memberships$,
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
delayId$: scope.behavior(
|
||||
(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MembershipManagerEvent.DelayIdChanged,
|
||||
// The type of reemitted event includes the original emitted as the second arg.
|
||||
) as Observable<[string | undefined, IMembershipManager]>
|
||||
).pipe(map(([delayId]) => delayId ?? null)),
|
||||
matrixRTCSession.delayId ?? null,
|
||||
),
|
||||
roomId: matrixRoom.roomId,
|
||||
forceJwtEndpoint:
|
||||
mode === MatrixRTCMode.Matrix_2_0
|
||||
? JwtEndpointVersion.Matrix_2_0
|
||||
: JwtEndpointVersion.Legacy,
|
||||
useOldestMember: mode === MatrixRTCMode.Legacy,
|
||||
}),
|
||||
),
|
||||
),
|
||||
useOldestMember$: scope.behavior(
|
||||
matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)),
|
||||
),
|
||||
});
|
||||
);
|
||||
|
||||
const connectionFactory = new ECConnectionFactory(
|
||||
client,
|
||||
matrixRoom.roomId,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
livekitKeyProvider,
|
||||
@@ -482,6 +488,7 @@ export function createCallViewModel$(
|
||||
connectionFactory: connectionFactory,
|
||||
localTransport$: scope.behavior(
|
||||
localTransport$.pipe(
|
||||
switchMap((t) => t.active$),
|
||||
catchError((e: unknown) => {
|
||||
logger.info(
|
||||
"could not pass local transport to createConnectionManager$. localTransport$ threw an error",
|
||||
@@ -496,12 +503,13 @@ export function createCallViewModel$(
|
||||
ownMembershipIdentity,
|
||||
});
|
||||
|
||||
const matrixLivekitMembers$ = createMatrixLivekitMembers$({
|
||||
scope: scope,
|
||||
membershipsWithTransport$:
|
||||
membershipsAndTransports.membershipsWithTransport$,
|
||||
connectionManager: connectionManager,
|
||||
});
|
||||
const matrixLivekitMembers$: Behavior<Epoch<RemoteMatrixLivekitMember[]>> =
|
||||
createMatrixLivekitMembers$({
|
||||
scope: scope,
|
||||
membershipsWithTransport$:
|
||||
membershipsAndTransports.membershipsWithTransport$,
|
||||
connectionManager: connectionManager,
|
||||
});
|
||||
|
||||
const connectOptions$ = scope.behavior(
|
||||
matrixRTCMode$.pipe(
|
||||
@@ -514,14 +522,14 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
const localMembership = createLocalMembership$({
|
||||
scope: scope,
|
||||
scope,
|
||||
homeserverConnected: createHomeserverConnected$(
|
||||
scope,
|
||||
client,
|
||||
matrixRTCSession,
|
||||
),
|
||||
muteStates: muteStates,
|
||||
joinMatrixRTC: (transport: LivekitTransport) => {
|
||||
muteStates,
|
||||
joinMatrixRTC: (transport: LivekitTransportConfig) => {
|
||||
return enterRTCSession(
|
||||
matrixRTCSession,
|
||||
ownMembershipIdentity,
|
||||
@@ -540,9 +548,11 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
},
|
||||
connectionManager: connectionManager,
|
||||
matrixRTCSession: matrixRTCSession,
|
||||
localTransport$: localTransport$,
|
||||
connectionManager,
|
||||
matrixRTCSession,
|
||||
localTransport$: scope.behavior(
|
||||
localTransport$.pipe(switchMap((t) => t.advertised$)),
|
||||
),
|
||||
logger: logger.getChild(`[${Date.now()}]`),
|
||||
});
|
||||
|
||||
@@ -704,7 +714,7 @@ export function createCallViewModel$(
|
||||
/**
|
||||
* List of user media (camera feeds) that we want tiles for.
|
||||
*/
|
||||
const userMedia$ = scope.behavior<UserMedia[]>(
|
||||
const userMedia$ = scope.behavior<WrappedUserMediaViewModel[]>(
|
||||
combineLatest([
|
||||
localMatrixLivekitMember$,
|
||||
matrixLivekitMembers$,
|
||||
@@ -713,6 +723,7 @@ export function createCallViewModel$(
|
||||
// Generate a collection of MediaItems from the list of expected (whether
|
||||
// present or missing) LiveKit participants.
|
||||
generateItems(
|
||||
"CallViewModel userMedia$",
|
||||
function* ([
|
||||
localMatrixLivekitMember,
|
||||
matrixLivekitMembers,
|
||||
@@ -749,36 +760,35 @@ export function createCallViewModel$(
|
||||
}
|
||||
}
|
||||
},
|
||||
(scope, _, dup, mediaId, userId, participant, connection$, rtcId) => {
|
||||
const livekitRoom$ = scope.behavior(
|
||||
connection$.pipe(map((c) => c?.livekitRoom)),
|
||||
);
|
||||
const focusUrl$ = scope.behavior(
|
||||
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
|
||||
);
|
||||
const displayName$ = scope.behavior(
|
||||
matrixMemberMetadataStore
|
||||
.createDisplayNameBehavior$(userId)
|
||||
.pipe(map((name) => name ?? userId)),
|
||||
);
|
||||
|
||||
return new UserMedia(
|
||||
scope,
|
||||
`${mediaId}:${dup}`,
|
||||
(scope, _, dup, mediaId, userId, participant, connection$, rtcId) =>
|
||||
createWrappedUserMedia(scope, {
|
||||
id: `${mediaId}:${dup}`,
|
||||
userId,
|
||||
rtcId,
|
||||
rtcBackendIdentity: rtcId,
|
||||
participant,
|
||||
options.encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
encryptionSystem: options.encryptionSystem,
|
||||
livekitRoom$: scope.behavior(
|
||||
connection$.pipe(map((c) => c?.livekitRoom)),
|
||||
),
|
||||
focusUrl$: scope.behavior(
|
||||
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
|
||||
),
|
||||
mediaDevices,
|
||||
localMembership.reconnecting$,
|
||||
displayName$,
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
|
||||
reactions$.pipe(map((v) => v[mediaId] ?? undefined)),
|
||||
);
|
||||
},
|
||||
pretendToBeDisconnected$: localMembership.reconnecting$,
|
||||
displayName$: scope.behavior(
|
||||
matrixMemberMetadataStore
|
||||
.createDisplayNameBehavior$(userId)
|
||||
.pipe(map((name) => name ?? userId)),
|
||||
),
|
||||
mxcAvatarUrl$:
|
||||
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
|
||||
handRaised$: scope.behavior(
|
||||
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
|
||||
),
|
||||
reaction$: scope.behavior(
|
||||
reactions$.pipe(map((v) => v[mediaId] ?? undefined)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -803,11 +813,9 @@ export function createCallViewModel$(
|
||||
/**
|
||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
||||
*/
|
||||
const screenShares$ = scope.behavior<ScreenShare[]>(
|
||||
const screenShares$ = scope.behavior<ScreenShareViewModel[]>(
|
||||
mediaItems$.pipe(
|
||||
map((mediaItems) =>
|
||||
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
|
||||
),
|
||||
map((mediaItems) => mediaItems.filter((m) => m.type === "screen share")),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -870,39 +878,39 @@ export function createCallViewModel$(
|
||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
||||
).pipe(scope.share);
|
||||
|
||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | null>(
|
||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||
userMedia$.pipe(
|
||||
switchMap((mediaItems) =>
|
||||
mediaItems.length === 0
|
||||
? of([])
|
||||
: combineLatest(
|
||||
mediaItems.map((m) =>
|
||||
m.vm.speaking$.pipe(map((s) => [m, s] as const)),
|
||||
m.speaking$.pipe(map((s) => [m, s] as const)),
|
||||
),
|
||||
),
|
||||
),
|
||||
scan<(readonly [UserMedia, boolean])[], UserMedia | undefined, null>(
|
||||
(prev, mediaItems) => {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const [stickyMedia, stickySpeaking] =
|
||||
(!prev?.vm.local && mediaItems.find(([m]) => m === prev)) || [];
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
return stickySpeaking
|
||||
? stickyMedia!
|
||||
: // Otherwise, select any remote user who is speaking
|
||||
(mediaItems.find(([m, s]) => !m.vm.local && s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
stickyMedia ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
mediaItems.find(([m]) => !m.vm.local)?.[0] ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.vm.local)?.[0]);
|
||||
},
|
||||
null,
|
||||
),
|
||||
map((speaker) => speaker?.vm ?? null),
|
||||
scan<
|
||||
(readonly [UserMediaViewModel, boolean])[],
|
||||
UserMediaViewModel | undefined,
|
||||
undefined
|
||||
>((prev, mediaItems) => {
|
||||
// Only remote users that are still in the call should be sticky
|
||||
const [stickyMedia, stickySpeaking] =
|
||||
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
|
||||
// Decide who to spotlight:
|
||||
// If the previous speaker is still speaking, stick with them rather
|
||||
// than switching eagerly to someone else
|
||||
return stickySpeaking
|
||||
? stickyMedia!
|
||||
: // Otherwise, select any remote user who is speaking
|
||||
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
|
||||
// Otherwise, stick with the person who was last speaking
|
||||
stickyMedia ??
|
||||
// Otherwise, spotlight an arbitrary remote user
|
||||
mediaItems.find(([m]) => !m.local)?.[0] ??
|
||||
// Otherwise, spotlight the local user
|
||||
mediaItems.find(([m]) => m.local)?.[0]);
|
||||
}, undefined),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -916,7 +924,7 @@ export function createCallViewModel$(
|
||||
return bins.length === 0
|
||||
? of([])
|
||||
: combineLatest(bins, (...bins) =>
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
@@ -926,9 +934,7 @@ export function createCallViewModel$(
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
screenShares$.pipe(
|
||||
switchMap((screenShares) => {
|
||||
if (screenShares.length > 0) {
|
||||
return of(screenShares.map((m) => m.vm));
|
||||
}
|
||||
if (screenShares.length > 0) return of(screenShares);
|
||||
|
||||
return spotlightSpeaker$.pipe(
|
||||
map((speaker) => (speaker ? [speaker] : [])),
|
||||
@@ -938,7 +944,7 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const pip$ = scope.behavior<UserMediaViewModel | null>(
|
||||
const pip$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||
combineLatest([
|
||||
// TODO This also needs epoch logic to dedupe the screenshares and mediaItems emits
|
||||
screenShares$,
|
||||
@@ -950,28 +956,17 @@ export function createCallViewModel$(
|
||||
return spotlightSpeaker$;
|
||||
}
|
||||
if (!spotlight || spotlight.local) {
|
||||
return of(null);
|
||||
return of(undefined);
|
||||
}
|
||||
|
||||
const localUserMedia = mediaItems.find(
|
||||
(m) => m.vm instanceof LocalUserMediaViewModel,
|
||||
) as UserMedia | undefined;
|
||||
|
||||
const localUserMediaViewModel = localUserMedia?.vm as
|
||||
| LocalUserMediaViewModel
|
||||
| undefined;
|
||||
|
||||
if (!localUserMediaViewModel) {
|
||||
return of(null);
|
||||
(m) => m.type === "user" && m.local,
|
||||
);
|
||||
if (!localUserMedia) {
|
||||
return of(undefined);
|
||||
}
|
||||
return localUserMediaViewModel.alwaysShow$.pipe(
|
||||
map((alwaysShow) => {
|
||||
if (alwaysShow) {
|
||||
return localUserMediaViewModel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
return localUserMedia.alwaysShow$.pipe(
|
||||
map((alwaysShow) => (alwaysShow ? localUserMedia : undefined)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -980,7 +975,7 @@ export function createCallViewModel$(
|
||||
const hasRemoteScreenShares$ = scope.behavior<boolean>(
|
||||
spotlight$.pipe(
|
||||
map((spotlight) =>
|
||||
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
|
||||
spotlight.some((vm) => vm.type === "screen share" && !vm.local),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1021,8 +1016,10 @@ export function createCallViewModel$(
|
||||
);
|
||||
|
||||
const spotlightExpandedToggle$ = new Subject<void>();
|
||||
const spotlightExpanded$ = scope.behavior<boolean>(
|
||||
spotlightExpandedToggle$.pipe(accumulate(false, (expanded) => !expanded)),
|
||||
const spotlightExpanded$ = createToggle$(
|
||||
scope,
|
||||
false,
|
||||
spotlightExpandedToggle$,
|
||||
);
|
||||
|
||||
const { setGridMode, gridMode$ } = createLayoutModeSwitch(
|
||||
@@ -1035,7 +1032,7 @@ export function createCallViewModel$(
|
||||
[grid$, spotlight$],
|
||||
(grid, spotlight) => ({
|
||||
type: "grid",
|
||||
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
spotlight: spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlight
|
||||
: undefined,
|
||||
grid,
|
||||
@@ -1067,12 +1064,8 @@ export function createCallViewModel$(
|
||||
mediaItems$.pipe(
|
||||
map((mediaItems) => {
|
||||
if (mediaItems.length !== 2) return null;
|
||||
const local = mediaItems.find((vm) => vm.vm.local)?.vm as
|
||||
| LocalUserMediaViewModel
|
||||
| undefined;
|
||||
const remote = mediaItems.find((vm) => !vm.vm.local)?.vm as
|
||||
| RemoteUserMediaViewModel
|
||||
| undefined;
|
||||
const local = mediaItems.find((vm) => vm.type === "user" && vm.local);
|
||||
const remote = mediaItems.find((vm) => vm.type === "user" && !vm.local);
|
||||
// There might not be a remote tile if there are screen shares, or if
|
||||
// only the local user is in the call and they're using the duplicate
|
||||
// tiles option
|
||||
@@ -1120,7 +1113,7 @@ export function createCallViewModel$(
|
||||
oneOnOne === null
|
||||
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
|
||||
grid.length > smallMobileCallThreshold ||
|
||||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
|
||||
spotlight.some((vm) => vm.type === "screen share")
|
||||
? spotlightPortraitLayoutMedia$
|
||||
: gridLayoutMedia$,
|
||||
).pipe(switchAll())
|
||||
@@ -1227,7 +1220,7 @@ export function createCallViewModel$(
|
||||
// screen sharing feeds are in the spotlight we still need them.
|
||||
return l.spotlight.media$.pipe(
|
||||
map((models: MediaViewModel[]) =>
|
||||
models.some((m) => m instanceof ScreenShareViewModel),
|
||||
models.some((m) => m.type === "screen share"),
|
||||
),
|
||||
);
|
||||
// In expanded spotlight layout, the active speaker is always shown in
|
||||
@@ -1278,7 +1271,7 @@ export function createCallViewModel$(
|
||||
switchMap((mode) => {
|
||||
switch (mode) {
|
||||
case "pip":
|
||||
return of(false);
|
||||
return of(platform === "desktop" ? true : false);
|
||||
case "normal":
|
||||
case "narrow":
|
||||
return of(true);
|
||||
@@ -1494,6 +1487,7 @@ export function createCallViewModel$(
|
||||
leave$: leave$,
|
||||
hangup: (): void => userHangup$.next(),
|
||||
join: localMembership.requestJoinAndPublish,
|
||||
leave: localMembership.requestDisconnect,
|
||||
toggleScreenSharing: toggleScreenSharing,
|
||||
sharingScreen$: sharingScreen$,
|
||||
|
||||
@@ -1533,17 +1527,21 @@ export function createCallViewModel$(
|
||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||
gridMode$: gridMode$,
|
||||
setGridMode: setGridMode,
|
||||
grid$: grid$,
|
||||
spotlight$: spotlight$,
|
||||
pip$: pip$,
|
||||
layout$: layout$,
|
||||
userMedia$,
|
||||
localMatrixLivekitMember$,
|
||||
matrixLivekitMembers$: scope.behavior(
|
||||
matrixLivekitMembers$.pipe(
|
||||
map((members) => members.value),
|
||||
tap((v) => {
|
||||
logger.debug("matrixLivekitMembers$ updated (exported)", v);
|
||||
const listForLogs = v
|
||||
.map(
|
||||
(m) =>
|
||||
m.membership$.value.userId + "|" + m.membership$.value.deviceId,
|
||||
)
|
||||
.join(",");
|
||||
logger.debug(
|
||||
`matrixLivekitMembers$ updated (exported) [${listForLogs}]`,
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
Status as RTCMemberStatus,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -39,7 +39,6 @@ import { constant } from "../../Behavior";
|
||||
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||
import { type Publisher } from "./Publisher";
|
||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport";
|
||||
import { initializeWidget } from "../../../widget";
|
||||
|
||||
initializeWidget();
|
||||
@@ -216,11 +215,10 @@ describe("LocalMembership", () => {
|
||||
|
||||
it("throws error on missing RTC config error", () => {
|
||||
withTestScheduler(({ scope, hot, expectObservable }) => {
|
||||
const localTransport$ =
|
||||
scope.behavior<null | LocalTransportWithSFUConfig>(
|
||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||
null,
|
||||
);
|
||||
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
|
||||
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
|
||||
null,
|
||||
);
|
||||
|
||||
// we do not need any connection data since we want to fail before reaching that.
|
||||
const mockConnectionManager = {
|
||||
@@ -279,23 +277,11 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
const aTransport = {
|
||||
transport: {
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransport,
|
||||
sfuConfig: {
|
||||
url: "sfu-url",
|
||||
jwt: "sfu-token",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
livekit_service_url: "a",
|
||||
} as LivekitTransportConfig;
|
||||
const bTransport = {
|
||||
transport: {
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransport,
|
||||
sfuConfig: {
|
||||
url: "sfu-url",
|
||||
jwt: "sfu-token",
|
||||
},
|
||||
} as LocalTransportWithSFUConfig;
|
||||
livekit_service_url: "b",
|
||||
} as LivekitTransportConfig;
|
||||
|
||||
const connectionTransportAConnected = {
|
||||
livekitRoom: mockLivekitRoom({
|
||||
@@ -305,7 +291,7 @@ describe("LocalMembership", () => {
|
||||
} as unknown as LocalParticipant,
|
||||
}),
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: aTransport.transport,
|
||||
transport: aTransport,
|
||||
} as unknown as Connection;
|
||||
const connectionTransportAConnecting = {
|
||||
...connectionTransportAConnected,
|
||||
@@ -314,7 +300,7 @@ describe("LocalMembership", () => {
|
||||
} as unknown as Connection;
|
||||
const connectionTransportBConnected = {
|
||||
state$: constant(ConnectionState.LivekitConnected),
|
||||
transport: bTransport.transport,
|
||||
transport: bTransport,
|
||||
livekitRoom: mockLivekitRoom({}),
|
||||
} as unknown as Connection;
|
||||
|
||||
@@ -368,12 +354,8 @@ describe("LocalMembership", () => {
|
||||
// stop the first Publisher and let the second one life.
|
||||
expect(publishers[0].destroy).toHaveBeenCalled();
|
||||
expect(publishers[1].destroy).not.toHaveBeenCalled();
|
||||
expect(publisherFactory.mock.calls[0][0].transport).toBe(
|
||||
aTransport.transport,
|
||||
);
|
||||
expect(publisherFactory.mock.calls[1][0].transport).toBe(
|
||||
bTransport.transport,
|
||||
);
|
||||
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
|
||||
@@ -446,8 +428,9 @@ describe("LocalMembership", () => {
|
||||
const scope = new ObservableScope();
|
||||
|
||||
const connectionManagerData = new ConnectionManagerData();
|
||||
const localTransport$ =
|
||||
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
|
||||
const localTransport$ = new BehaviorSubject<null | LivekitTransportConfig>(
|
||||
null,
|
||||
);
|
||||
const connectionManagerData$ = new BehaviorSubject(
|
||||
new Epoch(connectionManagerData),
|
||||
);
|
||||
@@ -519,7 +502,7 @@ describe("LocalMembership", () => {
|
||||
});
|
||||
|
||||
(
|
||||
connectionManagerData2.getConnectionForTransport(aTransport.transport)!
|
||||
connectionManagerData2.getConnectionForTransport(aTransport)!
|
||||
.state$ as BehaviorSubject<ConnectionState>
|
||||
).next(ConnectionState.LivekitConnected);
|
||||
expect(localMembership.localMemberState$.value).toStrictEqual({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { observeParticipantEvents } from "@livekit/components-core";
|
||||
import {
|
||||
Status as RTCSessionStatus,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
@@ -61,7 +62,6 @@ import {
|
||||
} from "../remoteMembers/Connection.ts";
|
||||
import { type HomeserverConnected } from "./HomeserverConnected.ts";
|
||||
import { and$ } from "../../../utils/observable.ts";
|
||||
import { type LocalTransportWithSFUConfig } from "./LocalTransport.ts";
|
||||
|
||||
export enum TransportState {
|
||||
/** Not even a transport is available to the LocalMembership */
|
||||
@@ -125,9 +125,9 @@ interface Props {
|
||||
muteStates: MuteStates;
|
||||
connectionManager: IConnectionManager;
|
||||
createPublisherFactory: (connection: Connection) => Publisher;
|
||||
joinMatrixRTC: (transport: LivekitTransport) => void;
|
||||
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
|
||||
homeserverConnected: HomeserverConnected;
|
||||
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
localTransport$: Behavior<LivekitTransportConfig | null>;
|
||||
matrixRTCSession: Pick<
|
||||
MatrixRTCSession,
|
||||
"updateCallIntent" | "leaveRoomSession"
|
||||
@@ -146,7 +146,7 @@ interface Props {
|
||||
* @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.localTransport$ The transport to advertise in our membership.
|
||||
* @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.
|
||||
@@ -236,9 +236,7 @@ export const createLocalMembership$ = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
return connectionData.getConnectionForTransport(
|
||||
localTransport.transport,
|
||||
);
|
||||
return connectionData.getConnectionForTransport(localTransport);
|
||||
}),
|
||||
tap((connection) => {
|
||||
logger.info(
|
||||
@@ -548,7 +546,7 @@ export const createLocalMembership$ = ({
|
||||
if (!shouldConnect) return;
|
||||
|
||||
try {
|
||||
joinMatrixRTC(transport.transport);
|
||||
joinMatrixRTC(transport);
|
||||
} catch (error) {
|
||||
logger.error("Error entering RTC session", error);
|
||||
if (error instanceof Error)
|
||||
@@ -717,7 +715,7 @@ interface EnterRTCSessionOptions {
|
||||
export function enterRTCSession(
|
||||
rtcSession: MatrixRTCSession,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
options: EnterRTCSessionOptions,
|
||||
): void {
|
||||
const { encryptMedia, matrixRTCMode } = options;
|
||||
@@ -735,12 +733,26 @@ export function enterRTCSession(
|
||||
const multiSFU =
|
||||
matrixRTCMode === MatrixRTCMode.Compatibility ||
|
||||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
|
||||
|
||||
// For backwards compatibility with Element Call versions that do not do Matrix 2.0,
|
||||
// we add the livekit alias to the transport.
|
||||
let backwardCompatibleTransport: LivekitTransport | LivekitTransportConfig;
|
||||
if (matrixRTCMode === MatrixRTCMode.Matrix_2_0) {
|
||||
backwardCompatibleTransport = transport;
|
||||
} else {
|
||||
backwardCompatibleTransport = {
|
||||
livekit_alias: rtcSession.room.roomId,
|
||||
...transport,
|
||||
};
|
||||
}
|
||||
|
||||
// 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.joinRTCSession(
|
||||
ownMembershipIdentity,
|
||||
multiSFU ? [] : [transport],
|
||||
multiSFU ? transport : undefined,
|
||||
multiSFU ? [] : [backwardCompatibleTransport],
|
||||
multiSFU ? backwardCompatibleTransport : undefined,
|
||||
{
|
||||
notificationType,
|
||||
callIntent,
|
||||
|
||||
@@ -13,15 +13,24 @@ import {
|
||||
it,
|
||||
type MockedObject,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
type CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, lastValueFrom } from "rxjs";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import { mockConfig, flushPromises, ownMemberMock } from "../../../utils/test";
|
||||
import {
|
||||
mockConfig,
|
||||
flushPromises,
|
||||
ownMemberMock,
|
||||
mockRtcMembership,
|
||||
} from "../../../utils/test";
|
||||
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
|
||||
import { constant } from "../../Behavior";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope";
|
||||
import {
|
||||
MatrixRTCTransportMissingError,
|
||||
FailToGetOpenIdToken,
|
||||
@@ -34,7 +43,7 @@ describe("LocalTransport", () => {
|
||||
const openIdResponse: openIDSFU.SFUConfig = {
|
||||
url: "https://lk.example.org",
|
||||
jwt: testJWTToken,
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
};
|
||||
|
||||
@@ -43,10 +52,10 @@ describe("LocalTransport", () => {
|
||||
afterEach(() => scope.end());
|
||||
|
||||
it("throws if config is missing", async () => {
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -58,14 +67,15 @@ describe("LocalTransport", () => {
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => localTransport$.value).toThrow(
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError(""));
|
||||
});
|
||||
|
||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||
@@ -83,10 +93,10 @@ describe("LocalTransport", () => {
|
||||
);
|
||||
const observations: unknown[] = [];
|
||||
const errors: Error[] = [];
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
baseUrl: "https://lk.example.org",
|
||||
@@ -98,10 +108,10 @@ describe("LocalTransport", () => {
|
||||
getDeviceId: vi.fn(),
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
localTransport$.subscribe(
|
||||
active$.subscribe(
|
||||
(o) => observations.push(o),
|
||||
(e) => errors.push(e),
|
||||
);
|
||||
@@ -111,7 +121,8 @@ describe("LocalTransport", () => {
|
||||
const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
|
||||
expect(observations).toStrictEqual([null]);
|
||||
expect(errors).toStrictEqual([expectedError]);
|
||||
expect(() => localTransport$.value).toThrow(expectedError);
|
||||
expect(() => advertised$.value).toThrow(expectedError);
|
||||
expect(() => active$.value).toThrow(expectedError);
|
||||
});
|
||||
|
||||
it("emits preferred transport after OpenID resolves", async () => {
|
||||
@@ -126,10 +137,10 @@ describe("LocalTransport", () => {
|
||||
openIdResolver.promise,
|
||||
);
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember$: constant(false),
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -140,80 +151,152 @@ describe("LocalTransport", () => {
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
openIdResolver.resolve?.({
|
||||
url: "https://lk.example.org",
|
||||
jwt: "jwt",
|
||||
livekitAlias: "!room:example.org",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
|
||||
});
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// final
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!room:example.org",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "jwt",
|
||||
livekitAlias: "!room:example.org",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@alice:example.org:DEVICE",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates local transport when oldest member changes", async () => {
|
||||
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
describe("oldest member mode", () => {
|
||||
const aliceTransport: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://alice.example.org",
|
||||
};
|
||||
const bobTransport: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://bob.example.org",
|
||||
};
|
||||
const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", {
|
||||
fociPreferred: [aliceTransport],
|
||||
});
|
||||
const memberships$ = new BehaviorSubject(new Epoch([]));
|
||||
const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
|
||||
|
||||
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
|
||||
openIdResolver.promise,
|
||||
);
|
||||
|
||||
const localTransport$ = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(true),
|
||||
memberships$,
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
delayId$: constant("delay_id_mock"),
|
||||
const bobMembership = mockRtcMembership("@bob:example.org", "BBB", {
|
||||
fociPreferred: [bobTransport],
|
||||
});
|
||||
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// final
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>;
|
||||
beforeEach(() => {
|
||||
openIdSpy = vi
|
||||
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||
.mockResolvedValue(openIdResponse);
|
||||
});
|
||||
|
||||
it("updates active transport when oldest member changes", async () => {
|
||||
// Initially, Alice is the only member
|
||||
const memberships$ = new BehaviorSubject([aliceMembership]);
|
||||
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// SFU config should've been fetched
|
||||
expect(openIdSpy).toHaveBeenCalled();
|
||||
// Alice's transport should be active and advertised
|
||||
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
|
||||
// Now Bob joins the call, but Alice is still the oldest member
|
||||
openIdSpy.mockClear();
|
||||
memberships$.next([aliceMembership, bobMembership]);
|
||||
await flushPromises();
|
||||
// No new SFU config should've been fetched
|
||||
expect(openIdSpy).not.toHaveBeenCalled();
|
||||
// Alice's transport should still be active and advertised
|
||||
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
|
||||
// Now Bob takes Alice's place as the oldest member
|
||||
openIdSpy.mockClear();
|
||||
memberships$.next([bobMembership, aliceMembership]);
|
||||
// Active transport should reset to null until we have Bob's SFU config
|
||||
expect(active$.value).toStrictEqual(null);
|
||||
await flushPromises();
|
||||
// Bob's SFU config should've been fetched
|
||||
expect(openIdSpy).toHaveBeenCalled();
|
||||
// Bob's transport should be active, but Alice's should remain advertised
|
||||
// (since we don't want the change in oldest member to cause a wave of new
|
||||
// state events)
|
||||
expect(active$.value?.transport).toStrictEqual(bobTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
});
|
||||
|
||||
it("advertises preferred transport when no other member exists", async () => {
|
||||
// Initially, there are no members
|
||||
const memberships$ = new BehaviorSubject<CallMembership[]>([]);
|
||||
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () =>
|
||||
Promise.resolve([aliceTransport]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// Our own preferred transport should be advertised
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// No transport should be active however (there is still no oldest member)
|
||||
expect(active$.value).toBe(null);
|
||||
|
||||
// Now Bob joins the call and becomes the oldest member
|
||||
memberships$.next([bobMembership]);
|
||||
await flushPromises();
|
||||
// We should still advertise our own preferred transport (to avoid
|
||||
// unnecessary state changes)
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// Bob's transport should become active
|
||||
expect(active$.value?.transport).toBe(bobTransport);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,8 +314,8 @@ describe("LocalTransport", () => {
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(false),
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant(null),
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
@@ -258,66 +341,75 @@ describe("LocalTransport", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "https://lk.example.org" },
|
||||
});
|
||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("supports getting transport via user settings", async () => {
|
||||
customLivekitUrl.setValue("https://lk.example.org");
|
||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
@@ -325,13 +417,11 @@ describe("LocalTransport", () => {
|
||||
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);
|
||||
}
|
||||
await expect(async () =>
|
||||
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||
});
|
||||
|
||||
it("supports getting transport via well-known", async () => {
|
||||
localTransportOpts.client.getDomain.mockReturnValue("example.org");
|
||||
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
|
||||
@@ -339,25 +429,29 @@ describe("LocalTransport", () => {
|
||||
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
|
||||
],
|
||||
});
|
||||
const localTransport$ = createLocalTransport$(localTransportOpts);
|
||||
const { advertised$, active$ } =
|
||||
createLocalTransport$(localTransportOpts);
|
||||
openIdResolver.resolve?.(openIdResponse);
|
||||
expect(localTransport$.value).toBe(null);
|
||||
expect(advertised$.value).toBe(null);
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
expect(localTransport$.value).toStrictEqual({
|
||||
transport: {
|
||||
livekit_alias: "!example_room_id",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
},
|
||||
const expectedTransport = {
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
type: "livekit",
|
||||
};
|
||||
expect(advertised$.value).toStrictEqual(expectedTransport);
|
||||
expect(active$.value).toStrictEqual({
|
||||
transport: expectedTransport,
|
||||
sfuConfig: {
|
||||
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
|
||||
livekitAlias: "!example_room_id",
|
||||
livekitAlias: "Akph4alDMhen",
|
||||
livekitIdentity: "@lk_user:ABCDEF",
|
||||
url: "https://lk.example.org",
|
||||
},
|
||||
});
|
||||
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", {
|
||||
@@ -368,20 +462,18 @@ describe("LocalTransport", () => {
|
||||
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);
|
||||
}
|
||||
await expect(async () =>
|
||||
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
|
||||
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
|
||||
});
|
||||
|
||||
it("throws if no options are available", async () => {
|
||||
const localTransport$ = createLocalTransport$({
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember$: constant(false),
|
||||
forceJwtEndpoint$: constant(JwtEndpointVersion.Legacy),
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant(null),
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
@@ -396,7 +488,10 @@ describe("LocalTransport", () => {
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(() => localTransport$.value).toThrow(
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,19 +7,21 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
isLivekitTransport,
|
||||
type LivekitTransport,
|
||||
isLivekitTransportConfig,
|
||||
type Transport,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { MatrixError, type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
@@ -57,9 +59,10 @@ interface Props {
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
> &
|
||||
OpenIDClientParts;
|
||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||
roomId: string;
|
||||
useOldestMember$: Behavior<boolean>;
|
||||
forceJwtEndpoint$: Behavior<JwtEndpointVersion>;
|
||||
useOldestMember: boolean;
|
||||
forceJwtEndpoint: JwtEndpointVersion;
|
||||
delayId$: Behavior<string | null>;
|
||||
}
|
||||
|
||||
@@ -90,26 +93,38 @@ export enum JwtEndpointVersion {
|
||||
// 2.
|
||||
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
|
||||
export interface LocalTransportWithSFUConfig {
|
||||
transport: LivekitTransport;
|
||||
transport: LivekitTransportConfig;
|
||||
sfuConfig: SFUConfig;
|
||||
}
|
||||
|
||||
export function isLocalTransportWithSFUConfig(
|
||||
obj: LivekitTransport | LocalTransportWithSFUConfig,
|
||||
obj: LivekitTransportConfig | LocalTransportWithSFUConfig,
|
||||
): obj is LocalTransportWithSFUConfig {
|
||||
return "transport" in obj && "sfuConfig" in obj;
|
||||
}
|
||||
|
||||
interface LocalTransport {
|
||||
/**
|
||||
* The transport to be advertised in our MatrixRTC membership. `null` when not
|
||||
* yet fetched/validated.
|
||||
*/
|
||||
advertised$: Behavior<LivekitTransportConfig | null>;
|
||||
/**
|
||||
* The transport to connect to and publish media on. `null` when not yet known
|
||||
* or available.
|
||||
*/
|
||||
active$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This class is responsible for managing the local transport.
|
||||
* "Which transport is the local member going to use"
|
||||
* Connects to the JWT service and determines the transports that the local member should use.
|
||||
*
|
||||
* @prop useOldestMember Whether to use the same transport as the oldest member.
|
||||
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
|
||||
*
|
||||
* @prop useOldJwtEndpoint$ Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
||||
* (which is expected for non sticky event based rtc member events)
|
||||
* @returns The local transport. It will be created using the correct sfu endpoint based on the useOldJwtEndpoint$ value.
|
||||
* @returns The transport to advertise in the local MatrixRTC membership, along with the transport to actively publish media to.
|
||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||
*/
|
||||
export const createLocalTransport$ = ({
|
||||
@@ -118,115 +133,156 @@ export const createLocalTransport$ = ({
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId,
|
||||
useOldestMember$,
|
||||
forceJwtEndpoint$,
|
||||
useOldestMember,
|
||||
forceJwtEndpoint,
|
||||
delayId$,
|
||||
}: Props): Behavior<LocalTransportWithSFUConfig | null> => {
|
||||
}: Props): LocalTransport => {
|
||||
/**
|
||||
* The transport over which we should be actively publishing our media.
|
||||
* undefined when not joined.
|
||||
* The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||
* oldest member has no such transport.
|
||||
*/
|
||||
const oldestMemberTransport$ =
|
||||
scope.behavior<LocalTransportWithSFUConfig | null>(
|
||||
combineLatest([memberships$, useOldestMember$]).pipe(
|
||||
map(([memberships, useOldestMember]) => {
|
||||
if (!useOldestMember) return null; // No need to do any prefetching if not using oldest member
|
||||
const oldestMember = memberships.value[0];
|
||||
const transport = oldestMember?.getTransport(oldestMember);
|
||||
if (!transport) return null;
|
||||
return transport;
|
||||
}),
|
||||
switchMap((transport) => {
|
||||
if (transport !== null && isLivekitTransport(transport)) {
|
||||
// Get the open jwt token to connect to the sfu
|
||||
const computeLocalTransportWithSFUConfig =
|
||||
async (): Promise<LocalTransportWithSFUConfig> => {
|
||||
// await sleep(1000);
|
||||
return {
|
||||
transport,
|
||||
sfuConfig: await getSFUConfigWithOpenID(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||
logger,
|
||||
),
|
||||
};
|
||||
};
|
||||
return from(computeLocalTransportWithSFUConfig());
|
||||
}
|
||||
return of(null);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
const oldestMemberTransport$ = scope.behavior<LivekitTransportConfig | null>(
|
||||
memberships$.pipe(
|
||||
map((memberships) => {
|
||||
const oldestMember = memberships.value[0];
|
||||
if (oldestMember === undefined) {
|
||||
logger.info("Oldest member: not found");
|
||||
return null;
|
||||
}
|
||||
const transport = oldestMember.getTransport(oldestMember);
|
||||
if (transport === undefined) {
|
||||
logger.warn(
|
||||
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!isLivekitTransportConfig(transport)) {
|
||||
logger.warn(
|
||||
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logger.info(
|
||||
"Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport",
|
||||
);
|
||||
return transport;
|
||||
}),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The transport that we would personally prefer to publish on (if not for the
|
||||
* transport preferences of others, perhaps).
|
||||
* transport preferences of others, perhaps). `null` until fetched and
|
||||
* validated.
|
||||
*
|
||||
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
|
||||
*/
|
||||
const preferredTransport$ = scope.behavior(
|
||||
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
||||
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
||||
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
||||
// When using sticky events (we need to use the new endpoint).
|
||||
combineLatest([customLivekitUrl.value$, delayId$, forceJwtEndpoint$]).pipe(
|
||||
switchMap(([customUrl, delayId, forceEndpoint]) => {
|
||||
logger.info(
|
||||
"Creating preferred transport based on: ",
|
||||
"customUrl: ",
|
||||
customUrl,
|
||||
"delayId: ",
|
||||
delayId,
|
||||
"forceEndpoint: ",
|
||||
forceEndpoint,
|
||||
);
|
||||
return from(
|
||||
makeTransport(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
customUrl,
|
||||
forceEndpoint,
|
||||
delayId ?? undefined,
|
||||
const preferredTransport$ =
|
||||
scope.behavior<LocalTransportWithSFUConfig | null>(
|
||||
// preferredTransport$ (used for multi sfu) needs to know if we are using the old or new
|
||||
// jwt endpoint (`get_token` vs `sfu/get`) based on that the jwt endpoint will compute the rtcBackendIdentity
|
||||
// differently. (sha(`${userId}|${deviceId}|${memberId}`) vs `${userId}|${deviceId}|${memberId}`)
|
||||
// When using sticky events (we need to use the new endpoint).
|
||||
customLivekitUrl.value$.pipe(
|
||||
switchMap((customUrl) =>
|
||||
startWith<LocalTransportWithSFUConfig | null>(null)(
|
||||
// Fetch the SFU config, and repeat this asynchronously for every
|
||||
// change in delay ID.
|
||||
delayId$.pipe(
|
||||
switchMap(async (delayId) => {
|
||||
logger.info(
|
||||
"Creating preferred transport based on: ",
|
||||
"customUrl: ",
|
||||
customUrl,
|
||||
"delayId: ",
|
||||
delayId,
|
||||
"forceJwtEndpoint: ",
|
||||
forceJwtEndpoint,
|
||||
);
|
||||
return makeTransport(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
customUrl,
|
||||
forceJwtEndpoint,
|
||||
delayId ?? undefined,
|
||||
);
|
||||
}),
|
||||
// We deliberately hide any changes to the SFU config because we
|
||||
// do not actually want the app to reconnect whenever the JWT
|
||||
// token changes due to us delegating a new delayed event. The
|
||||
// initial SFU config for the transport is all the app needs.
|
||||
distinctUntilChanged((prev, next) =>
|
||||
areLivekitTransportsEqual(prev.transport, next.transport),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The chosen transport we should advertise in our MatrixRTC membership.
|
||||
*/
|
||||
return scope.behavior(
|
||||
combineLatest([
|
||||
useOldestMember$,
|
||||
oldestMemberTransport$,
|
||||
preferredTransport$,
|
||||
]).pipe(
|
||||
map(([useOldestMember, oldestMemberTransport, preferredTransport]) => {
|
||||
return useOldestMember
|
||||
? (oldestMemberTransport ?? preferredTransport)
|
||||
: preferredTransport;
|
||||
}),
|
||||
distinctUntilChanged((t1, t2) => {
|
||||
logger.info(
|
||||
"Local Transport Update from:",
|
||||
t1?.transport.livekit_service_url,
|
||||
" to ",
|
||||
t2?.transport.livekit_service_url,
|
||||
);
|
||||
return areLivekitTransportsEqual(
|
||||
t1?.transport ?? null,
|
||||
t2?.transport ?? null,
|
||||
);
|
||||
}),
|
||||
if (useOldestMember) {
|
||||
// --- Oldest member mode ---
|
||||
return {
|
||||
// Never update the transport that we advertise in our membership. Just
|
||||
// take the first valid oldest member or preferred transport that we learn
|
||||
// about, and stick with that. This avoids unnecessary SFU hops and room
|
||||
// state changes.
|
||||
advertised$: scope.behavior(
|
||||
merge(
|
||||
oldestMemberTransport$,
|
||||
preferredTransport$.pipe(map((t) => t?.transport ?? null)),
|
||||
).pipe(
|
||||
first((t) => t !== null),
|
||||
tap((t) =>
|
||||
logger.info(`Advertise transport: ${t.livekit_service_url}`),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Publish on the transport used by the oldest member.
|
||||
active$: scope.behavior(
|
||||
oldestMemberTransport$.pipe(
|
||||
switchMap((transport) => {
|
||||
// Oldest member not available (or invalid SFU config).
|
||||
if (transport === null) return of(null);
|
||||
// Oldest member available: fetch the SFU config.
|
||||
const fetchOldestMemberTransport =
|
||||
async (): Promise<LocalTransportWithSFUConfig> => ({
|
||||
transport,
|
||||
sfuConfig: await getSFUConfigWithOpenID(
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
transport.livekit_service_url,
|
||||
roomId,
|
||||
{ forceJwtEndpoint: JwtEndpointVersion.Legacy },
|
||||
logger,
|
||||
),
|
||||
});
|
||||
return from(fetchOldestMemberTransport()).pipe(startWith(null));
|
||||
}),
|
||||
tap((t) =>
|
||||
logger.info(
|
||||
`Publish on transport: ${t?.transport.livekit_service_url}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// --- Multi-SFU mode ---
|
||||
// Always publish on and advertise the preferred transport.
|
||||
return {
|
||||
advertised$: scope.behavior(
|
||||
preferredTransport$.pipe(
|
||||
map((t) => t?.transport ?? null),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
),
|
||||
);
|
||||
active$: preferredTransport$,
|
||||
};
|
||||
};
|
||||
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
@@ -288,18 +344,6 @@ async function makeTransport(
|
||||
transport: {
|
||||
type: "livekit",
|
||||
livekit_service_url: url,
|
||||
// WARNING PLS READ ME!!!
|
||||
// This looks unintuitive especially considering that `sfuConfig.livekitAlias` exists.
|
||||
// Why do we not use: `livekit_alias: sfuConfig.livekitAlias`
|
||||
//
|
||||
// - This is going to be used for sending our state event transport (focus_preferred)
|
||||
// - In sticky events it is expected to NOT send this field at all. The transport is only the `type`, `livekit_service_url`
|
||||
// - If we set it to the hased alias we get from the jwt, we will end up using the hashed alias as the body.roomId field
|
||||
// in v0.16.0. (It will use oldest member transport. It is using the transport.livekit_alias as the body.roomId)
|
||||
//
|
||||
// TLDR this is a temporal field that allow for comaptibilty but the spec expects it to not exists. (but its existance also does not break anything)
|
||||
// It is just named poorly: It was intetended to be the actual alias. But now we do pseudonymys ids so we use a hashed alias.
|
||||
livekit_alias: roomId,
|
||||
},
|
||||
sfuConfig,
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
trackProcessorSync,
|
||||
} from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import { getUrlParams } from "../../../UrlParams.ts";
|
||||
import { observeTrackReference$ } from "../../MediaViewModel.ts";
|
||||
import { observeTrackReference$ } from "../../observeTrackReference";
|
||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import fetchMock from "fetch-mock";
|
||||
import EventEmitter from "events";
|
||||
import { type IOpenIDToken } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc/LivekitTransport";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
Connection,
|
||||
@@ -51,8 +51,9 @@ let fakeLivekitRoom: MockedObject<LivekitRoom>;
|
||||
let localParticipantEventEmiter: EventEmitter;
|
||||
let fakeLocalParticipant: MockedObject<LocalParticipant>;
|
||||
|
||||
const livekitFocus: LivekitTransport = {
|
||||
livekit_alias: "!roomID:example.org",
|
||||
const ROOM_ID = "!roomID:example.org";
|
||||
|
||||
const livekitFocus: LivekitTransportConfig = {
|
||||
livekit_service_url: "https://matrix-rtc.example.org/livekit/jwt",
|
||||
type: "livekit",
|
||||
};
|
||||
@@ -112,6 +113,7 @@ function setupTest(): void {
|
||||
function setupRemoteConnection(): Connection {
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -154,6 +156,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -170,6 +173,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -221,6 +225,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
@@ -279,6 +284,7 @@ describe("Start connection states", () => {
|
||||
|
||||
const opts: ConnectionOpts = {
|
||||
client: client,
|
||||
roomId: ROOM_ID,
|
||||
transport: livekitFocus,
|
||||
scope: testScope,
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type Room as LivekitRoom,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
@@ -49,9 +49,11 @@ export interface ConnectionOpts {
|
||||
/** The identity parts to use on this connection */
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** The media transport to connect to. */
|
||||
transport: LivekitTransport;
|
||||
transport: LivekitTransportConfig;
|
||||
/** The Matrix client to use for OpenID and SFU config requests. */
|
||||
client: OpenIDClientParts;
|
||||
/** The room ID this connection is associated with. */
|
||||
roomId: string;
|
||||
/** The observable scope to use for this connection. */
|
||||
scope: ObservableScope;
|
||||
|
||||
@@ -102,7 +104,7 @@ export class Connection {
|
||||
/**
|
||||
* The media transport to connect to.
|
||||
*/
|
||||
public readonly transport: LivekitTransport;
|
||||
public readonly transport: LivekitTransportConfig;
|
||||
|
||||
public readonly livekitRoom: LivekitRoom;
|
||||
|
||||
@@ -131,6 +133,47 @@ export class Connection {
|
||||
* */
|
||||
protected stopped = false;
|
||||
|
||||
// TODO: can we just keep the ConnectionOpts object instead of spreading?
|
||||
private readonly client: OpenIDClientParts;
|
||||
private readonly roomId: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
private readonly existingSFUConfig?: SFUConfig;
|
||||
/**
|
||||
* Creates a new connection to a matrix RTC LiveKit backend.
|
||||
*
|
||||
* @param opts - Connection options {@link ConnectionOpts}.
|
||||
*
|
||||
* @param logger - The logger to use.
|
||||
*/
|
||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||
this.existingSFUConfig = opts.existingSFUConfig;
|
||||
this.roomId = opts.roomId;
|
||||
this.logger = logger.getChild(
|
||||
"[Connection " + opts.transport.livekit_service_url + "]",
|
||||
);
|
||||
this.logger.info(
|
||||
`constructor: ${opts.transport.livekit_service_url} roomId: ${this.roomId} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`,
|
||||
);
|
||||
const { transport, client, scope } = opts;
|
||||
|
||||
this.scope = scope;
|
||||
this.livekitRoom = opts.livekitRoomFactory();
|
||||
this.transport = transport;
|
||||
this.client = client;
|
||||
|
||||
this.remoteParticipants$ = scope.behavior(
|
||||
// Only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
|
||||
scope.onEnd(() => {
|
||||
this.logger.info(`Connection scope ended, stopping connection`);
|
||||
void this.stop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the connection.
|
||||
*
|
||||
@@ -231,7 +274,7 @@ export class Connection {
|
||||
this.client,
|
||||
this.ownMembershipIdentity,
|
||||
this.transport.livekit_service_url,
|
||||
this.transport.livekit_alias,
|
||||
this.roomId,
|
||||
// dont pass any custom opts for the subscribe only connections
|
||||
{},
|
||||
this.logger,
|
||||
@@ -256,42 +299,4 @@ export class Connection {
|
||||
`stop: DONE disconnecing from lk room ${this.transport.livekit_service_url}`,
|
||||
);
|
||||
}
|
||||
|
||||
private readonly client: OpenIDClientParts;
|
||||
private readonly logger: Logger;
|
||||
private readonly ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
private readonly existingSFUConfig?: SFUConfig;
|
||||
/**
|
||||
* Creates a new connection to a matrix RTC LiveKit backend.
|
||||
*
|
||||
* @param opts - Connection options {@link ConnectionOpts}.
|
||||
*
|
||||
* @param logger - The logger to use.
|
||||
*/
|
||||
public constructor(opts: ConnectionOpts, logger: Logger) {
|
||||
this.ownMembershipIdentity = opts.ownMembershipIdentity;
|
||||
this.existingSFUConfig = opts.existingSFUConfig;
|
||||
this.logger = logger.getChild(
|
||||
"[Connection " + opts.transport.livekit_service_url + "]",
|
||||
);
|
||||
this.logger.info(
|
||||
`constructor: ${opts.transport.livekit_service_url} alias: ${opts.transport.livekit_alias} withSfuConfig?: ${opts.existingSFUConfig ? JSON.stringify(opts.existingSFUConfig) : "undefined"}`,
|
||||
);
|
||||
const { transport, client, scope } = opts;
|
||||
|
||||
this.scope = scope;
|
||||
this.livekitRoom = opts.livekitRoomFactory();
|
||||
this.transport = transport;
|
||||
this.client = client;
|
||||
|
||||
this.remoteParticipants$ = scope.behavior(
|
||||
// Only tracks remote participants
|
||||
connectedParticipantsObserver(this.livekitRoom),
|
||||
);
|
||||
|
||||
scope.onEnd(() => {
|
||||
this.logger.info(`Connection scope ended, stopping connection`);
|
||||
void this.stop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
// imported as inline to support worker when loaded from a cdn (cross domain)
|
||||
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc/LivekitTransport";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { type ObservableScope } from "../../ObservableScope.ts";
|
||||
import { Connection } from "./Connection.ts";
|
||||
@@ -33,7 +33,7 @@ import { defaultLiveKitOptions } from "../../../livekit/options.ts";
|
||||
export interface ConnectionFactory {
|
||||
createConnection(
|
||||
scope: ObservableScope,
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
sfuConfig?: SFUConfig,
|
||||
@@ -47,6 +47,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
* Creates a ConnectionFactory for LiveKit connections.
|
||||
*
|
||||
* @param client - The OpenID client parts for authentication, needed to get openID and JWT tokens.
|
||||
* @param roomId - The current room ID.
|
||||
* @param devices - Used for video/audio out/in capture options.
|
||||
* @param processorState$ - Effects like background blur (only for publishing connection?)
|
||||
* @param livekitKeyProvider - Optional key provider for end-to-end encryption.
|
||||
@@ -57,6 +58,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
*/
|
||||
public constructor(
|
||||
private client: OpenIDClientParts,
|
||||
private readonly roomId: string,
|
||||
private devices: MediaDevices,
|
||||
private processorState$: Behavior<ProcessorState>,
|
||||
livekitKeyProvider: BaseKeyProvider | undefined,
|
||||
@@ -95,7 +97,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
*/
|
||||
public createConnection(
|
||||
scope: ObservableScope,
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
logger: Logger,
|
||||
sfuConfig?: SFUConfig,
|
||||
@@ -103,6 +105,7 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
return new Connection(
|
||||
{
|
||||
existingSFUConfig: sfuConfig,
|
||||
roomId: this.roomId,
|
||||
transport,
|
||||
client: this.client,
|
||||
scope: scope,
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
@@ -24,16 +24,14 @@ import { constant, type Behavior } from "../../Behavior.ts";
|
||||
|
||||
// Some test constants
|
||||
|
||||
const TRANSPORT_1: LivekitTransport = {
|
||||
const TRANSPORT_1: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.example.org",
|
||||
livekit_alias: "!alias:example.org",
|
||||
};
|
||||
|
||||
const TRANSPORT_2: LivekitTransport = {
|
||||
const TRANSPORT_2: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://lk.sample.com",
|
||||
livekit_alias: "!alias:sample.com",
|
||||
};
|
||||
|
||||
let fakeConnectionFactory: ConnectionFactory;
|
||||
@@ -49,7 +47,7 @@ beforeEach(() => {
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
||||
const mockConnection = {
|
||||
transport,
|
||||
remoteParticipants$: new BehaviorSubject([]),
|
||||
@@ -209,15 +207,15 @@ describe("connectionManagerData$ stream", () => {
|
||||
// Used in test to control fake connections' remoteParticipants$ streams
|
||||
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
|
||||
|
||||
function keyForTransport(transport: LivekitTransport): string {
|
||||
return `${transport.livekit_service_url}|${transport.livekit_alias}`;
|
||||
function keyForTransport(transport: LivekitTransportConfig): string {
|
||||
return `${transport.livekit_service_url}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fakeRemoteParticipantsStreams = new Map();
|
||||
|
||||
function getRemoteParticipantsFor(
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
): Behavior<RemoteParticipant[]> {
|
||||
return (
|
||||
fakeRemoteParticipantsStreams.get(keyForTransport(transport)) ??
|
||||
@@ -229,7 +227,7 @@ describe("connectionManagerData$ stream", () => {
|
||||
vi.mocked(fakeConnectionFactory).createConnection = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(scope: ObservableScope, transport: LivekitTransport) => {
|
||||
(scope: ObservableScope, transport: LivekitTransportConfig) => {
|
||||
const fakeRemoteParticipants$ = new BehaviorSubject<
|
||||
RemoteParticipant[]
|
||||
>([]);
|
||||
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RemoteParticipant } from "livekit-client";
|
||||
@@ -42,8 +42,10 @@ export class ConnectionManagerData {
|
||||
}
|
||||
}
|
||||
|
||||
private getKey(transport: LivekitTransport): string {
|
||||
return transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||
private getKey(transport: LivekitTransportConfig): string {
|
||||
// This is enough as a key because the ConnectionManager is already scoped by room.
|
||||
// We also do not need to consider the slotId at this point since each `MatrixRTCSession` is already scoped by `slotDescription: {id, application}`.
|
||||
return transport.livekit_service_url;
|
||||
}
|
||||
|
||||
public getConnections(): Connection[] {
|
||||
@@ -51,15 +53,15 @@ export class ConnectionManagerData {
|
||||
}
|
||||
|
||||
public getConnectionForTransport(
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
): Connection | null {
|
||||
return this.store.get(this.getKey(transport))?.connection ?? null;
|
||||
}
|
||||
|
||||
public getParticipantsForTransport(
|
||||
transport: LivekitTransport,
|
||||
transport: LivekitTransportConfig,
|
||||
): RemoteParticipant[] {
|
||||
const key = transport.livekit_service_url + "|" + transport.livekit_alias;
|
||||
const key = this.getKey(transport);
|
||||
const existing = this.store.get(key);
|
||||
if (existing) {
|
||||
return existing.participants;
|
||||
@@ -72,7 +74,7 @@ interface Props {
|
||||
scope: ObservableScope;
|
||||
connectionFactory: ConnectionFactory;
|
||||
localTransport$: Behavior<LocalTransportWithSFUConfig | null>;
|
||||
remoteTransports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||
remoteTransports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
||||
|
||||
logger: Logger;
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
@@ -88,7 +90,7 @@ export interface IConnectionManager {
|
||||
* @param props - Configuration object
|
||||
* @param props.scope - The observable scope used by this object
|
||||
* @param props.connectionFactory - Used to create new connections
|
||||
* @param props.localTransport$ - The local transport to use. (deduplicated with remoteTransports$)
|
||||
* @param props.localTransport$ - The transport to publish local media on. (deduplicated with remoteTransports$)
|
||||
* @param props.remoteTransports$ - All other transports. The connection manager will create connections for each transport. (deduplicated with localTransport$)
|
||||
* @param props.ownMembershipIdentity - The own membership identity to use.
|
||||
* @param props.logger - The logger to use.
|
||||
@@ -123,7 +125,7 @@ export function createConnectionManager$({
|
||||
* externally this is modified via `registerTransports()`.
|
||||
*/
|
||||
const localAndRemoteTransports$: Behavior<
|
||||
Epoch<(LivekitTransport | LocalTransportWithSFUConfig)[]>
|
||||
Epoch<(LivekitTransportConfig | LocalTransportWithSFUConfig)[]>
|
||||
> = scope.behavior(
|
||||
combineLatest([remoteTransports$, localTransport$]).pipe(
|
||||
// Combine local and remote transports into one transport array
|
||||
@@ -160,44 +162,40 @@ export function createConnectionManager$({
|
||||
const connections$ = scope.behavior(
|
||||
localAndRemoteTransports$.pipe(
|
||||
generateItemsWithEpoch(
|
||||
"ConnectionManager connections$",
|
||||
function* (transports) {
|
||||
for (const transportWithOrWithoutSfuConfig of transports) {
|
||||
if (
|
||||
isLocalTransportWithSFUConfig(transportWithOrWithoutSfuConfig)
|
||||
) {
|
||||
// This is the local transport only the `LocalTransportWithSFUConfig` has a `sfuConfig` field
|
||||
const { transport, sfuConfig } = transportWithOrWithoutSfuConfig;
|
||||
for (const transport of transports) {
|
||||
if (isLocalTransportWithSFUConfig(transport)) {
|
||||
// This is the local transport; only the `LocalTransportWithSFUConfig` has a `sfuConfig` field.
|
||||
yield {
|
||||
keys: [
|
||||
transport.livekit_service_url,
|
||||
transport.livekit_alias,
|
||||
sfuConfig,
|
||||
transport.transport.livekit_service_url,
|
||||
transport.sfuConfig,
|
||||
],
|
||||
data: undefined,
|
||||
};
|
||||
} else {
|
||||
const transport = transportWithOrWithoutSfuConfig;
|
||||
yield {
|
||||
keys: [
|
||||
transport.livekit_service_url,
|
||||
transport.livekit_alias,
|
||||
undefined as undefined | SFUConfig,
|
||||
undefined as SFUConfig | undefined,
|
||||
],
|
||||
data: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
(scope, _data$, serviceUrl, alias, sfuConfig) => {
|
||||
(scope, _data$, serviceUrl, sfuConfig) => {
|
||||
const connection = connectionFactory.createConnection(
|
||||
scope,
|
||||
{
|
||||
type: "livekit",
|
||||
livekit_service_url: serviceUrl,
|
||||
livekit_alias: alias,
|
||||
},
|
||||
ownMembershipIdentity,
|
||||
logger,
|
||||
// TODO: This whole optional SFUConfig parameter is not particularly elegant.
|
||||
// I would like it if connections always fetched the SFUConfig by themselves.
|
||||
sfuConfig,
|
||||
);
|
||||
// Start the connection immediately
|
||||
@@ -254,7 +252,7 @@ export function createConnectionManager$({
|
||||
return { connectionManagerData$ };
|
||||
}
|
||||
|
||||
function removeDuplicateTransports<T extends LivekitTransport>(
|
||||
function removeDuplicateTransports<T extends LivekitTransportConfig>(
|
||||
transports: T[],
|
||||
): T[] {
|
||||
return transports.reduce((acc, transport) => {
|
||||
|
||||
@@ -65,6 +65,7 @@ describe("ECConnectionFactory - Audio inputs options", () => {
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
@@ -105,6 +106,7 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
||||
|
||||
const ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
mockMediaDevices({
|
||||
audioOutput: {
|
||||
available$: constant(new Map<never, never>()),
|
||||
|
||||
@@ -7,11 +7,10 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||
import {
|
||||
type LivekitTransport,
|
||||
type CallMembership,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { combineLatest, filter, map } from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../../Behavior";
|
||||
import { type IConnectionManager } from "./ConnectionManager";
|
||||
@@ -19,8 +18,6 @@ import { Epoch, type ObservableScope } from "../../ObservableScope";
|
||||
import { type Connection } from "./Connection";
|
||||
import { generateItemsWithEpoch } from "../../../utils/observable";
|
||||
|
||||
const logger = rootLogger.getChild("[MatrixLivekitMembers]");
|
||||
|
||||
interface LocalTaggedParticipant {
|
||||
type: "local";
|
||||
value$: Behavior<LocalParticipant | null>;
|
||||
@@ -62,7 +59,7 @@ export interface RemoteMatrixLivekitMember extends MatrixLivekitMember {
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
||||
>;
|
||||
connectionManager: IConnectionManager;
|
||||
}
|
||||
@@ -94,9 +91,10 @@ export function createMatrixLivekitMembers$({
|
||||
),
|
||||
map(([ms, data]) => new Epoch([ms.value, data.value] as const, ms.epoch)),
|
||||
generateItemsWithEpoch(
|
||||
"MatrixLivekitMembers",
|
||||
// Generator function.
|
||||
// creates an array of `{key, data}[]`
|
||||
// Each change in the keys (new key, missing key) will result in a call to the factory function.
|
||||
// Each change in the keys (new key) will result in a call to the factory function.
|
||||
function* ([membershipsWithTransport, managerData]) {
|
||||
for (const { membership, transport } of membershipsWithTransport) {
|
||||
const participants = transport
|
||||
@@ -111,26 +109,23 @@ export function createMatrixLivekitMembers$({
|
||||
: null;
|
||||
|
||||
yield {
|
||||
// This could also just be the memberId without the other fields.
|
||||
// In theory we should never have the same memberId for different userIds (they are UUIDs)
|
||||
// This still makes us resilient agains someone who intentionally tries to use the same memberId.
|
||||
// If they want to do this they would now need to also use the same sender which is impossible.
|
||||
// This could just be the backend identity without the other keys.
|
||||
// The user ID, device ID, and member ID are included however so
|
||||
// they show up in debug logs.
|
||||
keys: [
|
||||
membership.userId,
|
||||
membership.deviceId,
|
||||
membership.memberId,
|
||||
membership.rtcBackendIdentity,
|
||||
],
|
||||
data: { membership, participant, connection },
|
||||
};
|
||||
}
|
||||
},
|
||||
// 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$, userId, deviceId, memberId) => {
|
||||
logger.debug(
|
||||
`Generating member for livekitIdentity: ${data$.value.membership.rtcBackendIdentity},keys userId:deviceId:memberId ${userId}:${deviceId}:${memberId}`,
|
||||
);
|
||||
// Each update where the key of the generator array do not change will result in updates to the `data$` behavior.
|
||||
(scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => {
|
||||
const { participant$, ...rest } = scope.splitBehavior(data$);
|
||||
// will only get called once per `participantId, userId` pair.
|
||||
// will only get called once per backend identity.
|
||||
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
|
||||
return {
|
||||
userId,
|
||||
@@ -147,18 +142,12 @@ export function createMatrixLivekitMembers$({
|
||||
// TODO add back in the callviewmodel pauseWhen(this.pretendToBeDisconnected$)
|
||||
|
||||
// TODO add this to the JS-SDK
|
||||
export function areLivekitTransportsEqual<T extends LivekitTransport>(
|
||||
export function areLivekitTransportsEqual<T extends LivekitTransportConfig>(
|
||||
t1: T | null,
|
||||
t2: T | null,
|
||||
): boolean {
|
||||
if (t1 && t2)
|
||||
return (
|
||||
t1.livekit_service_url === t2.livekit_service_url &&
|
||||
// In case we have different lk rooms in the same SFU (depends on the livekit authorization service)
|
||||
// It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation)
|
||||
// Also LivekitTransport is planned to become a `ConnectionIdentifier` which moves this equal somewhere else.
|
||||
t1.livekit_alias === t2.livekit_alias
|
||||
);
|
||||
if (!t1 && !t2) return true;
|
||||
return false;
|
||||
if (t1 && t2) {
|
||||
return t1.livekit_service_url === t2.livekit_service_url;
|
||||
}
|
||||
return !t1 && !t2;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { BehaviorSubject } from "rxjs";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import EventEmitter from "events";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
@@ -71,6 +71,7 @@ beforeEach(() => {
|
||||
|
||||
ecConnectionFactory = new ECConnectionFactory(
|
||||
mockClient,
|
||||
"!roomid:example.org",
|
||||
mockMediaDevices({}),
|
||||
new BehaviorSubject<ProcessorState>({
|
||||
supported: true,
|
||||
@@ -148,7 +149,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
a: expect.toSatisfy((co) =>
|
||||
areLivekitTransportsEqual(
|
||||
co.transport,
|
||||
bobMembership.transports[0]! as LivekitTransport,
|
||||
bobMembership.transports[0]! as LivekitTransportConfig,
|
||||
),
|
||||
),
|
||||
});
|
||||
@@ -185,7 +186,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
expect(
|
||||
areLivekitTransportsEqual(
|
||||
connection.transport,
|
||||
carlMembership.transports[0]! as LivekitTransport,
|
||||
carlMembership.transports[0]! as LivekitTransportConfig,
|
||||
),
|
||||
).toBe(true);
|
||||
return true;
|
||||
@@ -215,7 +216,7 @@ test("bob, carl, then bob joining no tracks yet", () => {
|
||||
expect(
|
||||
areLivekitTransportsEqual(
|
||||
connection.transport,
|
||||
daveMembership.transports[0]! as LivekitTransport,
|
||||
daveMembership.transports[0]! as LivekitTransportConfig,
|
||||
),
|
||||
).toBe(true);
|
||||
return true;
|
||||
|
||||
@@ -1,807 +0,0 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type AudioSource,
|
||||
type VideoSource,
|
||||
type TrackReference,
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
roomEventSelector,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
LocalTrack,
|
||||
LocalVideoTrack,
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
Track,
|
||||
TrackEvent,
|
||||
facingModeFromLocalTrack,
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent as LivekitRoomEvent,
|
||||
RemoteTrack,
|
||||
} from "livekit-client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
type Observable,
|
||||
Subject,
|
||||
combineLatest,
|
||||
filter,
|
||||
fromEvent,
|
||||
interval,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
throttleTime,
|
||||
distinctUntilChanged,
|
||||
} from "rxjs";
|
||||
|
||||
import { alwaysShowSelf } from "../settings/settings";
|
||||
import { showConnectionStats } from "../settings/settings";
|
||||
import { accumulate } from "../utils/observable";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { platform } from "../Platform";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReference | undefined> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => participant.getTrackPublication(source)),
|
||||
distinctUntilChanged(),
|
||||
map((publication) => publication && { participant, publication, source }),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
type: "inbound-rtp" | "outbound-rtp",
|
||||
): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(participant, source),
|
||||
interval(1000).pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
const track = trackReference?.publication?.track;
|
||||
if (
|
||||
!track ||
|
||||
!(track instanceof RemoteTrack || track instanceof LocalTrack)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const report = await track.getRTCStatsReport();
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const v of report.values()) {
|
||||
if (v.type === type) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeInboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCInboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
||||
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeOutboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCOutboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe(
|
||||
map((x) => x as RTCOutboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function observeRemoteTrackReceivingOkay$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<boolean | undefined> {
|
||||
let lastStats: {
|
||||
framesDecoded: number | undefined;
|
||||
framesDropped: number | undefined;
|
||||
framesReceived: number | undefined;
|
||||
} = {
|
||||
framesDecoded: undefined,
|
||||
framesDropped: undefined,
|
||||
framesReceived: undefined,
|
||||
};
|
||||
|
||||
return observeInboundRtpStreamStats$(participant, source).pipe(
|
||||
map((stats) => {
|
||||
if (!stats) return undefined;
|
||||
const { framesDecoded, framesDropped, framesReceived } = stats;
|
||||
return {
|
||||
framesDecoded,
|
||||
framesDropped,
|
||||
framesReceived,
|
||||
};
|
||||
}),
|
||||
filter((newStats) => !!newStats),
|
||||
map((newStats): boolean | undefined => {
|
||||
const oldStats = lastStats;
|
||||
lastStats = newStats;
|
||||
if (
|
||||
typeof newStats.framesReceived === "number" &&
|
||||
typeof oldStats.framesReceived === "number" &&
|
||||
typeof newStats.framesDecoded === "number" &&
|
||||
typeof oldStats.framesDecoded === "number"
|
||||
) {
|
||||
const framesReceivedDelta =
|
||||
newStats.framesReceived - oldStats.framesReceived;
|
||||
const framesDecodedDelta =
|
||||
newStats.framesDecoded - oldStats.framesDecoded;
|
||||
|
||||
// if we received >0 frames and managed to decode >0 frames then we treat that as success
|
||||
|
||||
if (framesReceivedDelta > 0) {
|
||||
return framesDecodedDelta > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// no change
|
||||
return undefined;
|
||||
}),
|
||||
filter((x) => typeof x === "boolean"),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
|
||||
function encryptionErrorObservable$(
|
||||
room$: Behavior<LivekitRoom | undefined>,
|
||||
participant: Participant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
criteria: string,
|
||||
): Observable<boolean> {
|
||||
return room$.pipe(
|
||||
switchMap((room) => {
|
||||
if (room === undefined) return of(false);
|
||||
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
||||
map((e) => {
|
||||
const [err] = e;
|
||||
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return (
|
||||
// Ideally we would pull the participant identity from the field on the error.
|
||||
// However, it gets lost in the serialization process between workers.
|
||||
// So, instead we do a string match
|
||||
(err?.message.includes(participant.identity) &&
|
||||
err?.message.includes(criteria)) ??
|
||||
false
|
||||
);
|
||||
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
||||
return !!err?.message.includes(criteria);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||
startWith(false),
|
||||
);
|
||||
}
|
||||
|
||||
export enum EncryptionStatus {
|
||||
Connecting,
|
||||
Okay,
|
||||
KeyMissing,
|
||||
KeyInvalid,
|
||||
PasswordInvalid,
|
||||
}
|
||||
|
||||
abstract class BaseMediaViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
public readonly video$: Behavior<TrackReference | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
public readonly unencryptedWarning$: Behavior<boolean>;
|
||||
|
||||
public readonly encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
|
||||
/**
|
||||
* Whether this media corresponds to the local participant.
|
||||
*/
|
||||
public abstract readonly local: boolean;
|
||||
|
||||
private observeTrackReference$(
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReference | undefined> {
|
||||
return this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
protected readonly scope: ObservableScope,
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
*/
|
||||
public readonly id: string,
|
||||
/**
|
||||
* The Matrix user to which this media belongs.
|
||||
*/
|
||||
public readonly userId: string,
|
||||
public readonly rtcBackendIdentity: string,
|
||||
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
|
||||
// livekit.
|
||||
protected readonly participant$: Observable<
|
||||
LocalParticipant | RemoteParticipant | null
|
||||
>,
|
||||
|
||||
encryptionSystem: EncryptionSystem,
|
||||
audioSource: AudioSource,
|
||||
videoSource: VideoSource,
|
||||
protected readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
public readonly focusUrl$: Behavior<string | undefined>,
|
||||
public readonly displayName$: Behavior<string>,
|
||||
public readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
) {
|
||||
const audio$ = this.observeTrackReference$(audioSource);
|
||||
this.video$ = this.observeTrackReference$(videoSource);
|
||||
|
||||
this.unencryptedWarning$ = this.scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, this.video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication.isEncrypted === false ||
|
||||
v?.publication.isEncrypted === false),
|
||||
),
|
||||
);
|
||||
|
||||
this.encryptionStatus$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some participant's media.
|
||||
*/
|
||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||
export type UserMediaViewModel =
|
||||
| LocalUserMediaViewModel
|
||||
| RemoteUserMediaViewModel;
|
||||
|
||||
/**
|
||||
* Some participant's user media.
|
||||
*/
|
||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
private readonly _speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
),
|
||||
);
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
// Getter backed by a private field so that subclasses can override it
|
||||
public get speaking$(): Behavior<boolean> {
|
||||
return this._speaking$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this participant is sending audio (i.e. is unmuted on their side).
|
||||
*/
|
||||
public readonly audioEnabled$: Behavior<boolean>;
|
||||
|
||||
private readonly _videoEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether this participant is sending video.
|
||||
*/
|
||||
// Getter backed by a private field so that subclasses can override it
|
||||
public get videoEnabled$(): Behavior<boolean> {
|
||||
return this._videoEnabled$;
|
||||
}
|
||||
|
||||
private readonly _cropVideo$ = new BehaviorSubject(true);
|
||||
/**
|
||||
* Whether the tile video should be contained inside the tile or be cropped to fit.
|
||||
*/
|
||||
public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
public readonly handRaised$: Behavior<Date | null>,
|
||||
public readonly reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
Track.Source.Microphone,
|
||||
Track.Source.Camera,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
);
|
||||
|
||||
const media$ = this.scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
);
|
||||
this.audioEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
);
|
||||
this._videoEnabled$ = this.scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
);
|
||||
}
|
||||
|
||||
public toggleFitContain(): void {
|
||||
this._cropVideo$.next(!this._cropVideo$.value);
|
||||
}
|
||||
|
||||
public get local(): boolean {
|
||||
return this instanceof LocalUserMediaViewModel;
|
||||
}
|
||||
|
||||
public abstract get audioStreamStats$(): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
public abstract get videoStreamStats$(): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The local participant's user media.
|
||||
*/
|
||||
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* The local video track as an observable that emits whenever the track
|
||||
* changes, the camera is switched, or the track is muted.
|
||||
*/
|
||||
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||
this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication.track;
|
||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||
return merge(
|
||||
// Watch for track restarts because they indicate a camera switch.
|
||||
// This event is also emitted when unmuting the track object.
|
||||
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
map(() => track),
|
||||
),
|
||||
// When the track object is muted, reset it to null.
|
||||
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror$ = this.scope.behavior(
|
||||
this.videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null &&
|
||||
facingModeFromLocalTrack(track).facingMode === "user",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether to show this tile in a highly visible location near the start of
|
||||
* the grid.
|
||||
*/
|
||||
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
||||
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||
|
||||
/**
|
||||
* Callback for switching between the front and back cameras.
|
||||
*/
|
||||
public readonly switchCamera$: Behavior<(() => void) | null> =
|
||||
this.scope.behavior(
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: this.videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
this.mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Behavior<LocalParticipant | null>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
}
|
||||
|
||||
public audioStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeOutboundRtpStreamStats$(p, Track.Source.Microphone);
|
||||
}),
|
||||
);
|
||||
|
||||
public videoStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeOutboundRtpStreamStats$(p, Track.Source.Camera);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote participant's user media.
|
||||
*/
|
||||
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
|
||||
private __speaking$: Behavior<boolean>;
|
||||
public get speaking$(): Behavior<boolean> {
|
||||
return this.__speaking$;
|
||||
}
|
||||
|
||||
private readonly locallyMutedToggle$ = new Subject<void>();
|
||||
private readonly localVolumeAdjustment$ = new Subject<number>();
|
||||
private readonly localVolumeCommit$ = new Subject<void>();
|
||||
|
||||
/**
|
||||
* The volume to which this participant's audio is set, as a scalar
|
||||
* multiplier.
|
||||
*/
|
||||
public readonly localVolume$ = this.scope.behavior<number>(
|
||||
merge(
|
||||
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
|
||||
this.localVolumeAdjustment$,
|
||||
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
|
||||
).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
),
|
||||
);
|
||||
|
||||
// This private field is used to override the value from the superclass
|
||||
private __videoEnabled$: Behavior<boolean>;
|
||||
public get videoEnabled$(): Behavior<boolean> {
|
||||
return this.__videoEnabled$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this participant's audio is disabled.
|
||||
*/
|
||||
public readonly locallyMuted$ = this.scope.behavior<boolean>(
|
||||
this.localVolume$.pipe(map((volume) => volume === 0)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Observable<RemoteParticipant | null>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
);
|
||||
|
||||
this.__speaking$ = this.scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : super.speaking$,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.__videoEnabled$ = this.scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : super.videoEnabled$,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync the local volume with LiveKit
|
||||
combineLatest([
|
||||
participant$,
|
||||
// The local volume, taking into account whether we're supposed to pretend
|
||||
// that the audio stream is disconnected (since we don't necessarily want
|
||||
// that to modify the UI state).
|
||||
this.pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) => (disconnected ? of(0) : this.localVolume$)),
|
||||
this.scope.bind(),
|
||||
),
|
||||
]).subscribe(([p, volume]) => p?.setVolume(volume));
|
||||
}
|
||||
|
||||
public toggleLocallyMuted(): void {
|
||||
this.locallyMutedToggle$.next();
|
||||
}
|
||||
|
||||
public setLocalVolume(value: number): void {
|
||||
this.localVolumeAdjustment$.next(value);
|
||||
}
|
||||
|
||||
public commitLocalVolume(): void {
|
||||
this.localVolumeCommit$.next();
|
||||
}
|
||||
|
||||
public audioStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeInboundRtpStreamStats$(p, Track.Source.Microphone);
|
||||
}),
|
||||
);
|
||||
|
||||
public videoStreamStats$ = combineLatest([
|
||||
this.participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeInboundRtpStreamStats$(p, Track.Source.Camera);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Some participant's screen share media.
|
||||
*/
|
||||
export class ScreenShareViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether this screen share's video should be displayed.
|
||||
*/
|
||||
public readonly videoEnabled$ = this.scope.behavior(
|
||||
this.pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
public readonly local: boolean,
|
||||
) {
|
||||
super(
|
||||
scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
participant$,
|
||||
encryptionSystem,
|
||||
Track.Source.ScreenShareAudio,
|
||||
Track.Source.ScreenShare,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { platform } from "../Platform.ts";
|
||||
import { type PipLayout, type PipLayoutMedia } from "./layout-types.ts";
|
||||
import { type TileStore } from "./TileStore";
|
||||
|
||||
@@ -16,7 +17,11 @@ export function pipLayout(
|
||||
prevTiles: TileStore,
|
||||
): [PipLayout, TileStore] {
|
||||
const update = prevTiles.from(0);
|
||||
update.registerSpotlight(media.spotlight, true);
|
||||
// Dont maximise in pip on EW since we want the rounded corners and the footer
|
||||
update.registerSpotlight(
|
||||
media.spotlight,
|
||||
platform === "desktop" ? false : true,
|
||||
);
|
||||
const tiles = update.build();
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/*
|
||||
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 { of } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
import { ScreenShareViewModel } from "./MediaViewModel.ts";
|
||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||
import type { Behavior } from "./Behavior.ts";
|
||||
|
||||
/**
|
||||
* A screen share media item to be presented in a tile. This is a thin wrapper
|
||||
* around ScreenShareViewModel which essentially just establishes an
|
||||
* ObservableScope for behaviors that the view model depends on.
|
||||
*/
|
||||
export class ScreenShare {
|
||||
public readonly vm: ScreenShareViewModel;
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
id: string,
|
||||
userId: string,
|
||||
rtcBackendIdentity: string,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
focusUrl$: Behavior<string | undefined>,
|
||||
pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Behavior<string>,
|
||||
mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
) {
|
||||
this.vm = new ScreenShareViewModel(
|
||||
this.scope,
|
||||
id,
|
||||
userId,
|
||||
rtcBackendIdentity,
|
||||
of(participant),
|
||||
encryptionSystem,
|
||||
livekitRoom$,
|
||||
focusUrl$,
|
||||
pretendToBeDisconnected$,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
participant.isLocal,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
isLivekitTransport,
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
isLivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { fromEvent } from "rxjs";
|
||||
|
||||
@@ -27,19 +27,26 @@ export const membershipsAndTransports$ = (
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
): {
|
||||
membershipsWithTransport$: Behavior<
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransport }[]>
|
||||
Epoch<{ membership: CallMembership; transport?: LivekitTransportConfig }[]>
|
||||
>;
|
||||
transports$: Behavior<Epoch<LivekitTransport[]>>;
|
||||
transports$: Behavior<Epoch<LivekitTransportConfig[]>>;
|
||||
} => {
|
||||
/**
|
||||
* Lists the transports used by ourselves, plus all other MatrixRTC session
|
||||
* members. For completeness this also lists the preferred transport and
|
||||
* whether we are in multi-SFU mode or sticky events mode (because
|
||||
* advertisedTransport$ wants to read them at the same time, and bundling data
|
||||
* together when it might change together is what you have to do in RxJS to
|
||||
* avoid reading inconsistent state or observing too many changes.)
|
||||
* members.
|
||||
* For completeness this also lists the preferred transport and
|
||||
* whether we are in multi-SFU mode or sticky events mode.
|
||||
* `advertisedTransport$` reads these values together, so bundling them avoids inconsistent state or
|
||||
* excessive updates when using RxJS.
|
||||
*/
|
||||
const membershipsWithTransport$ = scope.behavior(
|
||||
const membershipsWithTransport$: Behavior<
|
||||
Epoch<
|
||||
{
|
||||
membership: CallMembership;
|
||||
transport: LivekitTransportConfig | undefined;
|
||||
}[]
|
||||
>
|
||||
> = scope.behavior(
|
||||
memberships$.pipe(
|
||||
mapEpoch((memberships) => {
|
||||
return memberships.map((membership) => {
|
||||
@@ -47,14 +54,16 @@ export const membershipsAndTransports$ = (
|
||||
const transport = membership.getTransport(oldestMembership);
|
||||
return {
|
||||
membership,
|
||||
transport: isLivekitTransport(transport) ? transport : undefined,
|
||||
transport: isLivekitTransportConfig(transport)
|
||||
? transport
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const transports$ = scope.behavior(
|
||||
const transports$: Behavior<Epoch<LivekitTransportConfig[]>> = scope.behavior(
|
||||
membershipsWithTransport$.pipe(
|
||||
mapEpoch((mts) => mts.flatMap(({ transport: t }) => (t ? [t] : []))),
|
||||
),
|
||||
|
||||
@@ -8,10 +8,11 @@ Please see LICENSE in the repository root for full details.
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { GridTileViewModel, SpotlightTileViewModel } from "./TileViewModel";
|
||||
import { fillGaps } from "../utils/iter";
|
||||
import { debugTileLayout } from "../settings/settings";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||
|
||||
function debugEntries(entries: GridTileData[]): string[] {
|
||||
return entries.map((e) => e.media.displayName$.value);
|
||||
|
||||
@@ -5,8 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel";
|
||||
|
||||
let nextId = 0;
|
||||
function createId(): string {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest, map, type Observable, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
type Room as LivekitRoom,
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
|
||||
import { type ObservableScope } from "./ObservableScope.ts";
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "./MediaViewModel.ts";
|
||||
import type { Behavior } from "./Behavior.ts";
|
||||
import type { EncryptionSystem } from "../e2ee/sharedKeyManagement.ts";
|
||||
import type { MediaDevices } from "./MediaDevices.ts";
|
||||
import type { ReactionOption } from "../reactions";
|
||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||
import { generateItems } from "../utils/observable.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.
|
||||
*/
|
||||
enum SortingBin {
|
||||
/**
|
||||
* Yourself, when the "always show self" option is on.
|
||||
*/
|
||||
SelfAlwaysShown,
|
||||
/**
|
||||
* Participants that are sharing their screen.
|
||||
*/
|
||||
Presenters,
|
||||
/**
|
||||
* Participants that have been speaking recently.
|
||||
*/
|
||||
Speakers,
|
||||
/**
|
||||
* Participants that have their hand raised.
|
||||
*/
|
||||
HandRaised,
|
||||
/**
|
||||
* Participants with video.
|
||||
*/
|
||||
Video,
|
||||
/**
|
||||
* Participants not sharing any video.
|
||||
*/
|
||||
NoVideo,
|
||||
/**
|
||||
* Yourself, when the "always show self" option is off.
|
||||
*/
|
||||
SelfNotAlwaysShown,
|
||||
}
|
||||
|
||||
/**
|
||||
* A user media item to be presented in a tile. This is a thin wrapper around
|
||||
* UserMediaViewModel which additionally determines the media item's sorting bin
|
||||
* for inclusion in the call layout and tracks associated screen shares.
|
||||
*/
|
||||
export class UserMedia {
|
||||
public readonly vm: UserMediaViewModel =
|
||||
this.participant.type === "local"
|
||||
? new LocalUserMediaViewModel(
|
||||
this.scope,
|
||||
this.id,
|
||||
this.userId,
|
||||
this.rtcBackendIdentity,
|
||||
this.participant.value$,
|
||||
this.encryptionSystem,
|
||||
this.livekitRoom$,
|
||||
this.focusUrl$,
|
||||
this.mediaDevices,
|
||||
this.displayName$,
|
||||
this.mxcAvatarUrl$,
|
||||
this.scope.behavior(this.handRaised$),
|
||||
this.scope.behavior(this.reaction$),
|
||||
)
|
||||
: new RemoteUserMediaViewModel(
|
||||
this.scope,
|
||||
this.id,
|
||||
this.userId,
|
||||
this.rtcBackendIdentity,
|
||||
this.participant.value$,
|
||||
this.encryptionSystem,
|
||||
this.livekitRoom$,
|
||||
this.focusUrl$,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.displayName$,
|
||||
this.mxcAvatarUrl$,
|
||||
this.scope.behavior(this.handRaised$),
|
||||
this.scope.behavior(this.reaction$),
|
||||
);
|
||||
|
||||
private readonly speaker$ = this.scope.behavior(
|
||||
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.
|
||||
*/
|
||||
public readonly screenShares$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === null
|
||||
? of([])
|
||||
: observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(
|
||||
// Technically more than one screen share might be possible... our
|
||||
// MediaViewModels don't support it though since they look for a unique
|
||||
// track for the given source. So generateItems here is a bit overkill.
|
||||
generateItems(
|
||||
function* (p) {
|
||||
if (p.isScreenShareEnabled)
|
||||
yield {
|
||||
keys: ["screen-share"],
|
||||
data: undefined,
|
||||
};
|
||||
},
|
||||
(scope, _data$, key) =>
|
||||
new ScreenShare(
|
||||
scope,
|
||||
`${this.id}:${key}`,
|
||||
this.userId,
|
||||
this.rtcBackendIdentity,
|
||||
p,
|
||||
this.encryptionSystem,
|
||||
this.livekitRoom$,
|
||||
this.focusUrl$,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.displayName$,
|
||||
this.mxcAvatarUrl$,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
private readonly presenter$ = this.scope.behavior(
|
||||
this.screenShares$.pipe(map((screenShares) => screenShares.length > 0)),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which sorting bin the media item should be placed in.
|
||||
*/
|
||||
// This is exposed here rather than by UserMediaViewModel because it's only
|
||||
// relevant to the layout algorithms; the MediaView component should be
|
||||
// ignorant of this value.
|
||||
public readonly bin$ = combineLatest(
|
||||
[
|
||||
this.speaker$,
|
||||
this.presenter$,
|
||||
this.vm.videoEnabled$,
|
||||
this.vm.handRaised$,
|
||||
this.vm instanceof LocalUserMediaViewModel
|
||||
? this.vm.alwaysShow$
|
||||
: of(false),
|
||||
],
|
||||
(speaker, presenter, video, handRaised, alwaysShow) => {
|
||||
if (this.vm.local)
|
||||
return alwaysShow
|
||||
? SortingBin.SelfAlwaysShown
|
||||
: SortingBin.SelfNotAlwaysShown;
|
||||
else if (presenter) return SortingBin.Presenters;
|
||||
else if (speaker) return SortingBin.Speakers;
|
||||
else if (handRaised) return SortingBin.HandRaised;
|
||||
else if (video) return SortingBin.Video;
|
||||
else return SortingBin.NoVideo;
|
||||
},
|
||||
);
|
||||
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
public readonly id: string,
|
||||
private readonly userId: string,
|
||||
private readonly rtcBackendIdentity: string,
|
||||
private readonly participant: TaggedParticipant,
|
||||
private readonly encryptionSystem: EncryptionSystem,
|
||||
private readonly livekitRoom$: Behavior<LivekitRoom | undefined>,
|
||||
private readonly focusUrl$: Behavior<string | undefined>,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
private readonly pretendToBeDisconnected$: Behavior<boolean>,
|
||||
private readonly displayName$: Behavior<string>,
|
||||
private readonly mxcAvatarUrl$: Behavior<string | undefined>,
|
||||
private readonly handRaised$: Observable<Date | null>,
|
||||
private readonly reaction$: Observable<ReactionOption | null>,
|
||||
) {}
|
||||
}
|
||||
101
src/state/VolumeControls.ts
Normal file
101
src/state/VolumeControls.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2026 Element Software 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, merge, of, Subject, switchMap } from "rxjs";
|
||||
|
||||
import { type Behavior } from "./Behavior";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { accumulate } from "../utils/observable";
|
||||
|
||||
/**
|
||||
* Controls for audio playback volume.
|
||||
*/
|
||||
export interface VolumeControls {
|
||||
/**
|
||||
* The volume to which the audio is set, as a scalar multiplier.
|
||||
*/
|
||||
playbackVolume$: Behavior<number>;
|
||||
/**
|
||||
* Whether playback of this audio is disabled.
|
||||
*/
|
||||
playbackMuted$: Behavior<boolean>;
|
||||
togglePlaybackMuted: () => void;
|
||||
adjustPlaybackVolume: (value: number) => void;
|
||||
commitPlaybackVolume: () => void;
|
||||
}
|
||||
|
||||
interface VolumeControlsInputs {
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
/**
|
||||
* The callback to run to notify the module performing audio playback of the
|
||||
* requested volume.
|
||||
*/
|
||||
sink$: Behavior<(volume: number) => void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a set of controls for audio playback volume and syncs this with the
|
||||
* audio playback module for the duration of the scope.
|
||||
*/
|
||||
export function createVolumeControls(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, sink$ }: VolumeControlsInputs,
|
||||
): VolumeControls {
|
||||
const toggleMuted$ = new Subject<"toggle mute">();
|
||||
const adjustVolume$ = new Subject<number>();
|
||||
const commitVolume$ = new Subject<"commit">();
|
||||
|
||||
const playbackVolume$ = scope.behavior<number>(
|
||||
merge(toggleMuted$, adjustVolume$, commitVolume$).pipe(
|
||||
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
|
||||
switch (event) {
|
||||
case "toggle mute":
|
||||
return {
|
||||
...state,
|
||||
volume: state.volume === 0 ? state.committedVolume : 0,
|
||||
};
|
||||
case "commit":
|
||||
// Dragging the slider to zero should have the same effect as
|
||||
// muting: keep the original committed volume, as if it were never
|
||||
// dragged
|
||||
return {
|
||||
...state,
|
||||
committedVolume:
|
||||
state.volume === 0 ? state.committedVolume : state.volume,
|
||||
};
|
||||
default:
|
||||
// Volume adjustment
|
||||
return { ...state, volume: event };
|
||||
}
|
||||
}),
|
||||
map(({ volume }) => volume),
|
||||
),
|
||||
);
|
||||
|
||||
// Sync the requested volume with the audio playback module
|
||||
combineLatest([
|
||||
sink$,
|
||||
// The playback volume, taking into account whether we're supposed to
|
||||
// pretend that the audio stream is disconnected (since we don't necessarily
|
||||
// want that to modify the UI state).
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) => (disconnected ? of(0) : playbackVolume$)),
|
||||
),
|
||||
])
|
||||
.pipe(scope.bind())
|
||||
.subscribe(([sink, volume]) => sink(volume));
|
||||
|
||||
return {
|
||||
playbackVolume$,
|
||||
playbackMuted$: scope.behavior<boolean>(
|
||||
playbackVolume$.pipe(map((volume) => volume === 0)),
|
||||
),
|
||||
togglePlaybackMuted: () => toggleMuted$.next("toggle mute"),
|
||||
adjustPlaybackVolume: (value: number) => adjustVolume$.next(value),
|
||||
commitPlaybackVolume: () => commitVolume$.next("commit"),
|
||||
};
|
||||
}
|
||||
@@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type LocalUserMediaViewModel } from "./media/LocalUserMediaViewModel.ts";
|
||||
import { type MediaViewModel } from "./media/MediaViewModel.ts";
|
||||
import { type RemoteUserMediaViewModel } from "./media/RemoteUserMediaViewModel.ts";
|
||||
import { type UserMediaViewModel } from "./media/UserMediaViewModel.ts";
|
||||
import {
|
||||
type GridTileViewModel,
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import {
|
||||
type MediaViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "./MediaViewModel.ts";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
@@ -40,8 +40,8 @@ export interface SpotlightExpandedLayoutMedia {
|
||||
|
||||
export interface OneOnOneLayoutMedia {
|
||||
type: "one-on-one";
|
||||
local: UserMediaViewModel;
|
||||
remote: UserMediaViewModel;
|
||||
local: LocalUserMediaViewModel;
|
||||
remote: RemoteUserMediaViewModel;
|
||||
}
|
||||
|
||||
export interface PipLayoutMedia {
|
||||
|
||||
32
src/state/media/LocalScreenShareViewModel.ts
Normal file
32
src/state/media/LocalScreenShareViewModel.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 { type LocalParticipant } from "livekit-client";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseScreenShareInputs,
|
||||
type BaseScreenShareViewModel,
|
||||
createBaseScreenShare,
|
||||
} from "./ScreenShareViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export interface LocalScreenShareViewModel extends BaseScreenShareViewModel {
|
||||
local: true;
|
||||
}
|
||||
|
||||
export interface LocalScreenShareInputs extends BaseScreenShareInputs {
|
||||
participant$: Behavior<LocalParticipant | null>;
|
||||
}
|
||||
|
||||
export function createLocalScreenShare(
|
||||
scope: ObservableScope,
|
||||
inputs: LocalScreenShareInputs,
|
||||
): LocalScreenShareViewModel {
|
||||
return { ...createBaseScreenShare(scope, inputs), local: true };
|
||||
}
|
||||
137
src/state/media/LocalUserMediaViewModel.ts
Normal file
137
src/state/media/LocalUserMediaViewModel.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 {
|
||||
facingModeFromLocalTrack,
|
||||
type LocalParticipant,
|
||||
LocalVideoTrack,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseUserMediaInputs,
|
||||
type BaseUserMediaViewModel,
|
||||
createBaseUserMedia,
|
||||
} from "./UserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { alwaysShowSelf } from "../../settings/settings";
|
||||
import { platform } from "../../Platform";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
|
||||
export interface LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
local: true;
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
mirror$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether to show this tile in a highly visible location near the start of
|
||||
* the grid.
|
||||
*/
|
||||
alwaysShow$: Behavior<boolean>;
|
||||
setAlwaysShow: (value: boolean) => void;
|
||||
switchCamera$: Behavior<(() => void) | null>;
|
||||
}
|
||||
|
||||
export interface LocalUserMediaInputs extends Omit<
|
||||
BaseUserMediaInputs,
|
||||
"statsType"
|
||||
> {
|
||||
participant$: Behavior<LocalParticipant | null>;
|
||||
mediaDevices: MediaDevices;
|
||||
}
|
||||
|
||||
export function createLocalUserMedia(
|
||||
scope: ObservableScope,
|
||||
{ mediaDevices, ...inputs }: LocalUserMediaInputs,
|
||||
): LocalUserMediaViewModel {
|
||||
const baseUserMedia = createBaseUserMedia(scope, {
|
||||
...inputs,
|
||||
statsType: "outbound-rtp",
|
||||
});
|
||||
|
||||
/**
|
||||
* The local video track as an observable that emits whenever the track
|
||||
* changes, the camera is switched, or the track is muted.
|
||||
*/
|
||||
const videoTrack$: Observable<LocalVideoTrack | null> =
|
||||
baseUserMedia.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication.track;
|
||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||
return merge(
|
||||
// Watch for track restarts because they indicate a camera switch.
|
||||
// This event is also emitted when unmuting the track object.
|
||||
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
map(() => track),
|
||||
),
|
||||
// When the track object is muted, reset it to null.
|
||||
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...baseUserMedia,
|
||||
local: true,
|
||||
mirror$: scope.behavior(
|
||||
videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null &&
|
||||
facingModeFromLocalTrack(track).facingMode === "user",
|
||||
),
|
||||
),
|
||||
),
|
||||
alwaysShow$: alwaysShowSelf.value$,
|
||||
setAlwaysShow: alwaysShowSelf.setValue,
|
||||
switchCamera$: scope.behavior(
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
198
src/state/media/MediaItem.ts
Normal file
198
src/state/media/MediaItem.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
Copyright 2025-2026 Element Software 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, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
} from "livekit-client";
|
||||
import { observeParticipantEvents } from "@livekit/components-core";
|
||||
|
||||
import { type ObservableScope } from "../ObservableScope.ts";
|
||||
import type { Behavior } from "../Behavior.ts";
|
||||
import type { MediaDevices } from "../MediaDevices.ts";
|
||||
import { observeSpeaker$ } from "./observeSpeaker.ts";
|
||||
import { generateItems } from "../../utils/observable.ts";
|
||||
import { type TaggedParticipant } from "../CallViewModel/remoteMembers/MatrixLivekitMembers.ts";
|
||||
import { type UserMediaViewModel } from "./UserMediaViewModel.ts";
|
||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel.ts";
|
||||
import {
|
||||
createLocalUserMedia,
|
||||
type LocalUserMediaInputs,
|
||||
} from "./LocalUserMediaViewModel.ts";
|
||||
import {
|
||||
createRemoteUserMedia,
|
||||
type RemoteUserMediaInputs,
|
||||
} from "./RemoteUserMediaViewModel.ts";
|
||||
import { createLocalScreenShare } from "./LocalScreenShareViewModel.ts";
|
||||
import { createRemoteScreenShare } from "./RemoteScreenShareViewModel.ts";
|
||||
|
||||
/**
|
||||
* Sorting bins defining the order in which media tiles appear in the layout.
|
||||
*/
|
||||
enum SortingBin {
|
||||
/**
|
||||
* Yourself, when the "always show self" option is on.
|
||||
*/
|
||||
SelfAlwaysShown,
|
||||
/**
|
||||
* Participants that are sharing their screen.
|
||||
*/
|
||||
Presenters,
|
||||
/**
|
||||
* Participants that have been speaking recently.
|
||||
*/
|
||||
Speakers,
|
||||
/**
|
||||
* Participants that have their hand raised.
|
||||
*/
|
||||
HandRaised,
|
||||
/**
|
||||
* Participants with video.
|
||||
*/
|
||||
Video,
|
||||
/**
|
||||
* Participants not sharing any video.
|
||||
*/
|
||||
NoVideo,
|
||||
/**
|
||||
* Yourself, when the "always show self" option is off.
|
||||
*/
|
||||
SelfNotAlwaysShown,
|
||||
}
|
||||
|
||||
/**
|
||||
* A user media item to be presented in a tile. This is a thin wrapper around
|
||||
* UserMediaViewModel which additionally carries data relevant to the tile
|
||||
* layout algorithms (data which the MediaView component should be ignorant of).
|
||||
*/
|
||||
export type WrappedUserMediaViewModel = UserMediaViewModel & {
|
||||
/**
|
||||
* All screen share media associated with this user media.
|
||||
*/
|
||||
screenShares$: Behavior<ScreenShareViewModel[]>;
|
||||
/**
|
||||
* Which sorting bin the media item should be placed in.
|
||||
*/
|
||||
bin$: Behavior<SortingBin>;
|
||||
};
|
||||
|
||||
interface WrappedUserMediaInputs extends Omit<
|
||||
LocalUserMediaInputs & RemoteUserMediaInputs,
|
||||
"participant$"
|
||||
> {
|
||||
participant: TaggedParticipant;
|
||||
mediaDevices: MediaDevices;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export function createWrappedUserMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
participant,
|
||||
mediaDevices,
|
||||
pretendToBeDisconnected$,
|
||||
...inputs
|
||||
}: WrappedUserMediaInputs,
|
||||
): WrappedUserMediaViewModel {
|
||||
const userMedia =
|
||||
participant.type === "local"
|
||||
? createLocalUserMedia(scope, {
|
||||
participant$: participant.value$,
|
||||
mediaDevices,
|
||||
...inputs,
|
||||
})
|
||||
: createRemoteUserMedia(scope, {
|
||||
participant$: participant.value$,
|
||||
pretendToBeDisconnected$,
|
||||
...inputs,
|
||||
});
|
||||
|
||||
// TypeScript needs this widening of the type to happen in a separate statement
|
||||
const participant$: Behavior<LocalParticipant | RemoteParticipant | null> =
|
||||
participant.value$;
|
||||
|
||||
const screenShares$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p === null
|
||||
? of([])
|
||||
: observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.TrackPublished,
|
||||
ParticipantEvent.TrackUnpublished,
|
||||
ParticipantEvent.LocalTrackPublished,
|
||||
ParticipantEvent.LocalTrackUnpublished,
|
||||
).pipe(
|
||||
// Technically more than one screen share might be possible... our
|
||||
// MediaViewModels don't support it though since they look for a unique
|
||||
// track for the given source. So generateItems here is a bit overkill.
|
||||
generateItems(
|
||||
`${inputs.id} screenShares$`,
|
||||
function* (p) {
|
||||
if (p.isScreenShareEnabled)
|
||||
yield {
|
||||
keys: ["screen-share"],
|
||||
data: undefined,
|
||||
};
|
||||
},
|
||||
(scope, _data$, key) => {
|
||||
const id = `${inputs.id}:${key}`;
|
||||
return participant.type === "local"
|
||||
? createLocalScreenShare(scope, {
|
||||
...inputs,
|
||||
id,
|
||||
participant$: participant.value$,
|
||||
})
|
||||
: createRemoteScreenShare(scope, {
|
||||
...inputs,
|
||||
id,
|
||||
participant$: participant.value$,
|
||||
pretendToBeDisconnected$,
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const speaker$ = scope.behavior(observeSpeaker$(userMedia.speaking$));
|
||||
const presenter$ = scope.behavior(
|
||||
screenShares$.pipe(map((screenShares) => screenShares.length > 0)),
|
||||
);
|
||||
|
||||
return {
|
||||
...userMedia,
|
||||
screenShares$,
|
||||
bin$: scope.behavior(
|
||||
combineLatest(
|
||||
[
|
||||
speaker$,
|
||||
presenter$,
|
||||
userMedia.videoEnabled$,
|
||||
userMedia.handRaised$,
|
||||
userMedia.local ? userMedia.alwaysShow$ : of<boolean | null>(null),
|
||||
],
|
||||
(speaker, presenter, video, handRaised, alwaysShow) => {
|
||||
if (alwaysShow !== null)
|
||||
return alwaysShow
|
||||
? SortingBin.SelfAlwaysShown
|
||||
: SortingBin.SelfNotAlwaysShown;
|
||||
else if (presenter) return SortingBin.Presenters;
|
||||
else if (speaker) return SortingBin.Speakers;
|
||||
else if (handRaised) return SortingBin.HandRaised;
|
||||
else if (video) return SortingBin.Video;
|
||||
else return SortingBin.NoVideo;
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export type MediaItem = WrappedUserMediaViewModel | ScreenShareViewModel;
|
||||
@@ -9,6 +9,7 @@ import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
@@ -17,13 +18,13 @@ import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
createLocalMedia,
|
||||
createRemoteMedia,
|
||||
mockLocalMedia,
|
||||
mockRemoteMedia,
|
||||
withTestScheduler,
|
||||
mockRemoteParticipant,
|
||||
} from "../utils/test";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { constant } from "./Behavior";
|
||||
mockRemoteScreenShare,
|
||||
} from "../../utils/test";
|
||||
import { constant } from "../Behavior";
|
||||
|
||||
global.MediaStreamTrack = class {} as unknown as {
|
||||
new (): MediaStreamTrack;
|
||||
@@ -35,7 +36,7 @@ global.MediaStream = class {} as unknown as {
|
||||
};
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
vi.mock("../../Platform", () => ({
|
||||
get platform(): string {
|
||||
return platformMock();
|
||||
},
|
||||
@@ -45,7 +46,7 @@ const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
|
||||
test("control a participant's volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = createRemoteMedia(
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
@@ -54,33 +55,33 @@ test("control a participant's volume", () => {
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
vm.togglePlaybackMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.setLocalVolume(0.6);
|
||||
vm.setLocalVolume(0.8);
|
||||
vm.commitLocalVolume();
|
||||
vm.adjustPlaybackVolume(0.6);
|
||||
vm.adjustPlaybackVolume(0.8);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.6);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.setLocalVolume(0.2);
|
||||
vm.setLocalVolume(0);
|
||||
vm.commitLocalVolume();
|
||||
vm.adjustPlaybackVolume(0.2);
|
||||
vm.adjustPlaybackVolume(0);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(0.2);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.toggleLocallyMuted();
|
||||
vm.togglePlaybackMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
|
||||
expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", {
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0.6,
|
||||
@@ -92,23 +93,75 @@ test("control a participant's volume", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("toggle fit/contain for a participant's video", () => {
|
||||
const vm = createRemoteMedia(rtcMembership, {}, mockRemoteParticipant({}));
|
||||
test("control a participant's screen share volume", () => {
|
||||
const setVolumeSpy = vi.fn();
|
||||
const vm = mockRemoteScreenShare(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({ setVolume: setVolumeSpy }),
|
||||
);
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-ab|", {
|
||||
a: () => vm.toggleFitContain(),
|
||||
b: () => vm.toggleFitContain(),
|
||||
schedule("-ab---c---d|", {
|
||||
a() {
|
||||
// Try muting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
b() {
|
||||
// Try unmuting by dragging the slider back up
|
||||
vm.adjustPlaybackVolume(0.6);
|
||||
vm.adjustPlaybackVolume(0.8);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||
0.6,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0.8,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
c() {
|
||||
// Try muting by dragging the slider back down
|
||||
vm.adjustPlaybackVolume(0.2);
|
||||
vm.adjustPlaybackVolume(0);
|
||||
vm.commitPlaybackVolume();
|
||||
expect(setVolumeSpy).toHaveBeenCalledWith(
|
||||
0.2,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
d() {
|
||||
// Try unmuting by toggling
|
||||
vm.togglePlaybackMuted();
|
||||
// The volume should return to the last non-zero committed volume
|
||||
expect(setVolumeSpy).toHaveBeenLastCalledWith(
|
||||
0.8,
|
||||
Track.Source.ScreenShareAudio,
|
||||
);
|
||||
},
|
||||
});
|
||||
expectObservable(vm.cropVideo$).toBe("abc", {
|
||||
a: true,
|
||||
b: false,
|
||||
c: true,
|
||||
expectObservable(vm.playbackVolume$).toBe("ab(cd)(ef)g", {
|
||||
a: 1,
|
||||
b: 0,
|
||||
c: 0.6,
|
||||
d: 0.8,
|
||||
e: 0.2,
|
||||
f: 0,
|
||||
g: 0.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", () => {
|
||||
const vm1 = createLocalMedia(
|
||||
const vm1 = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
@@ -120,7 +173,7 @@ test("local media remembers whether it should always be shown", () => {
|
||||
});
|
||||
|
||||
// Next local media should start out *not* always shown
|
||||
const vm2 = createLocalMedia(
|
||||
const vm2 = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
@@ -166,7 +219,7 @@ test("switch cameras", async () => {
|
||||
|
||||
const selectVideoInput = vi.fn();
|
||||
|
||||
const vm = createLocalMedia(
|
||||
const vm = mockLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({
|
||||
@@ -184,7 +237,7 @@ test("switch cameras", async () => {
|
||||
);
|
||||
|
||||
// Switch to back camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
vm.switchCamera$.value!();
|
||||
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||
facingMode: "environment",
|
||||
});
|
||||
@@ -195,7 +248,7 @@ test("switch cameras", async () => {
|
||||
expect(deviceId).toBe("back camera");
|
||||
|
||||
// Switch to front camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
vm.switchCamera$.value!();
|
||||
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||
await waitFor(() => {
|
||||
@@ -206,17 +259,17 @@ test("switch cameras", async () => {
|
||||
});
|
||||
|
||||
test("remote media is in waiting state when participant has not yet connected", () => {
|
||||
const vm = createRemoteMedia(rtcMembership, {}, null); // null participant
|
||||
const vm = mockRemoteMedia(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({}));
|
||||
const vm = mockRemoteMedia(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(
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({
|
||||
@@ -228,7 +281,7 @@ test("remote media is not in waiting state when participant is connected with no
|
||||
});
|
||||
|
||||
test("remote media is not in waiting state when user does not intend to publish anywhere", () => {
|
||||
const vm = createRemoteMedia(
|
||||
const vm = mockRemoteMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
44
src/state/media/MediaViewModel.ts
Normal file
44
src/state/media/MediaViewModel.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 { type Behavior } from "../Behavior";
|
||||
import { type ScreenShareViewModel } from "./ScreenShareViewModel";
|
||||
import { type UserMediaViewModel } from "./UserMediaViewModel";
|
||||
|
||||
/**
|
||||
* A participant's media.
|
||||
*/
|
||||
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
|
||||
|
||||
/**
|
||||
* Properties which are common to all MediaViewModels.
|
||||
*/
|
||||
export interface BaseMediaViewModel {
|
||||
/**
|
||||
* An opaque identifier for this media.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The Matrix user to which this media belongs.
|
||||
*/
|
||||
userId: string;
|
||||
displayName$: Behavior<string>;
|
||||
mxcAvatarUrl$: Behavior<string | undefined>;
|
||||
}
|
||||
|
||||
export type BaseMediaInputs = BaseMediaViewModel;
|
||||
|
||||
// All this function does is strip out superfluous data from the input object
|
||||
export function createBaseMedia({
|
||||
id,
|
||||
userId,
|
||||
displayName$,
|
||||
mxcAvatarUrl$,
|
||||
}: BaseMediaInputs): BaseMediaViewModel {
|
||||
return { id, userId, displayName$, mxcAvatarUrl$ };
|
||||
}
|
||||
272
src/state/media/MemberMediaViewModel.ts
Normal file
272
src/state/media/MemberMediaViewModel.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 {
|
||||
type Room as LivekitRoom,
|
||||
RoomEvent as LivekitRoomEvent,
|
||||
type Participant,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
type AudioSource,
|
||||
roomEventSelector,
|
||||
type TrackReference,
|
||||
type VideoSource,
|
||||
} from "@livekit/components-core";
|
||||
import { type LocalParticipant, type RemoteParticipant } from "livekit-client";
|
||||
import {
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
throttleTime,
|
||||
} from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type BaseMediaViewModel, createBaseMedia } from "./MediaViewModel";
|
||||
import { type EncryptionSystem } from "../../e2ee/sharedKeyManagement";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { observeInboundRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||
|
||||
// TODO: Encryption status is kinda broken and thus unused right now. Remove?
|
||||
export enum EncryptionStatus {
|
||||
Connecting,
|
||||
Okay,
|
||||
KeyMissing,
|
||||
KeyInvalid,
|
||||
PasswordInvalid,
|
||||
}
|
||||
|
||||
/**
|
||||
* Media belonging to an active member of the RTC session.
|
||||
*/
|
||||
export interface MemberMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* The LiveKit video track for this media.
|
||||
*/
|
||||
video$: Behavior<TrackReference | undefined>;
|
||||
/**
|
||||
* The URL of the LiveKit focus on which this member should be publishing.
|
||||
* Exposed for debugging.
|
||||
*/
|
||||
focusUrl$: Behavior<string | undefined>;
|
||||
/**
|
||||
* Whether there should be a warning that this media is unencrypted.
|
||||
*/
|
||||
unencryptedWarning$: Behavior<boolean>;
|
||||
encryptionStatus$: Behavior<EncryptionStatus>;
|
||||
}
|
||||
|
||||
export interface MemberMediaInputs extends BaseMediaViewModel {
|
||||
participant$: Behavior<LocalParticipant | RemoteParticipant | null>;
|
||||
livekitRoom$: Behavior<LivekitRoom | undefined>;
|
||||
audioSource: AudioSource;
|
||||
videoSource: VideoSource;
|
||||
focusUrl$: Behavior<string | undefined>;
|
||||
encryptionSystem: EncryptionSystem;
|
||||
}
|
||||
|
||||
export function createMemberMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
participant$,
|
||||
livekitRoom$,
|
||||
audioSource,
|
||||
videoSource,
|
||||
focusUrl$,
|
||||
encryptionSystem,
|
||||
...inputs
|
||||
}: MemberMediaInputs,
|
||||
): MemberMediaViewModel {
|
||||
const trackBehavior$ = (
|
||||
source: Track.Source,
|
||||
): Behavior<TrackReference | undefined> =>
|
||||
scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
!p ? of(undefined) : observeTrackReference$(p, source),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const audio$ = trackBehavior$(audioSource);
|
||||
const video$ = trackBehavior$(videoSource);
|
||||
|
||||
return {
|
||||
...createBaseMedia(inputs),
|
||||
video$,
|
||||
focusUrl$,
|
||||
unencryptedWarning$: scope.behavior(
|
||||
combineLatest(
|
||||
[audio$, video$],
|
||||
(a, v) =>
|
||||
encryptionSystem.kind !== E2eeType.NONE &&
|
||||
(a?.publication.isEncrypted === false ||
|
||||
v?.publication.isEncrypted === false),
|
||||
),
|
||||
),
|
||||
encryptionStatus$: scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((participant): Observable<EncryptionStatus> => {
|
||||
if (!participant) {
|
||||
return of(EncryptionStatus.Connecting);
|
||||
} else if (
|
||||
participant.isLocal ||
|
||||
encryptionSystem.kind === E2eeType.NONE
|
||||
) {
|
||||
return of(EncryptionStatus.Okay);
|
||||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"MissingKey",
|
||||
),
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
|
||||
if (keyMissing) return EncryptionStatus.KeyMissing;
|
||||
if (keyInvalid) return EncryptionStatus.KeyInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
}),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
} else {
|
||||
return combineLatest([
|
||||
encryptionErrorObservable$(
|
||||
livekitRoom$,
|
||||
participant,
|
||||
encryptionSystem,
|
||||
"InvalidKey",
|
||||
),
|
||||
observeRemoteTrackReceivingOkay$(participant, audioSource),
|
||||
observeRemoteTrackReceivingOkay$(participant, videoSource),
|
||||
]).pipe(
|
||||
map(
|
||||
([keyInvalid, audioOkay, videoOkay]):
|
||||
| EncryptionStatus
|
||||
| undefined => {
|
||||
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
|
||||
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
|
||||
return undefined; // no change
|
||||
},
|
||||
),
|
||||
filter((x) => !!x),
|
||||
startWith(EncryptionStatus.Connecting),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function encryptionErrorObservable$(
|
||||
room$: Behavior<LivekitRoom | undefined>,
|
||||
participant: Participant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
criteria: string,
|
||||
): Observable<boolean> {
|
||||
return room$.pipe(
|
||||
switchMap((room) => {
|
||||
if (room === undefined) return of(false);
|
||||
return roomEventSelector(room, LivekitRoomEvent.EncryptionError).pipe(
|
||||
map((e) => {
|
||||
const [err] = e;
|
||||
if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
|
||||
return (
|
||||
// Ideally we would pull the participant identity from the field on the error.
|
||||
// However, it gets lost in the serialization process between workers.
|
||||
// So, instead we do a string match
|
||||
(err?.message.includes(participant.identity) &&
|
||||
err?.message.includes(criteria)) ??
|
||||
false
|
||||
);
|
||||
} else if (encryptionSystem.kind === E2eeType.SHARED_KEY) {
|
||||
return !!err?.message.includes(criteria);
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
throttleTime(1000), // Throttle to avoid spamming the UI
|
||||
startWith(false),
|
||||
);
|
||||
}
|
||||
|
||||
function observeRemoteTrackReceivingOkay$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<boolean | undefined> {
|
||||
let lastStats: {
|
||||
framesDecoded: number | undefined;
|
||||
framesDropped: number | undefined;
|
||||
framesReceived: number | undefined;
|
||||
} = {
|
||||
framesDecoded: undefined,
|
||||
framesDropped: undefined,
|
||||
framesReceived: undefined,
|
||||
};
|
||||
|
||||
return observeInboundRtpStreamStats$(participant, source).pipe(
|
||||
map((stats) => {
|
||||
if (!stats) return undefined;
|
||||
const { framesDecoded, framesDropped, framesReceived } = stats;
|
||||
return {
|
||||
framesDecoded,
|
||||
framesDropped,
|
||||
framesReceived,
|
||||
};
|
||||
}),
|
||||
filter((newStats) => !!newStats),
|
||||
map((newStats): boolean | undefined => {
|
||||
const oldStats = lastStats;
|
||||
lastStats = newStats;
|
||||
if (
|
||||
typeof newStats.framesReceived === "number" &&
|
||||
typeof oldStats.framesReceived === "number" &&
|
||||
typeof newStats.framesDecoded === "number" &&
|
||||
typeof oldStats.framesDecoded === "number"
|
||||
) {
|
||||
const framesReceivedDelta =
|
||||
newStats.framesReceived - oldStats.framesReceived;
|
||||
const framesDecodedDelta =
|
||||
newStats.framesDecoded - oldStats.framesDecoded;
|
||||
|
||||
// if we received >0 frames and managed to decode >0 frames then we treat that as success
|
||||
|
||||
if (framesReceivedDelta > 0) {
|
||||
return framesDecodedDelta > 0;
|
||||
}
|
||||
}
|
||||
|
||||
// no change
|
||||
return undefined;
|
||||
}),
|
||||
filter((x) => typeof x === "boolean"),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
72
src/state/media/RemoteScreenShareViewModel.ts
Normal file
72
src/state/media/RemoteScreenShareViewModel.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 { Track, type RemoteParticipant } from "livekit-client";
|
||||
import { map, of, switchMap } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import {
|
||||
type BaseScreenShareInputs,
|
||||
type BaseScreenShareViewModel,
|
||||
createBaseScreenShare,
|
||||
} from "./ScreenShareViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
|
||||
export interface RemoteScreenShareViewModel
|
||||
extends BaseScreenShareViewModel, VolumeControls {
|
||||
local: false;
|
||||
/**
|
||||
* Whether this screen share's video should be displayed.
|
||||
*/
|
||||
videoEnabled$: Behavior<boolean>;
|
||||
/**
|
||||
* Whether this screen share should be considered to have an audio track.
|
||||
*/
|
||||
audioEnabled$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteScreenShareInputs extends BaseScreenShareInputs {
|
||||
participant$: Behavior<RemoteParticipant | null>;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export function createRemoteScreenShare(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, ...inputs }: RemoteScreenShareInputs,
|
||||
): RemoteScreenShareViewModel {
|
||||
return {
|
||||
...createBaseScreenShare(scope, inputs),
|
||||
...createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(
|
||||
map(
|
||||
(p) => (volume) =>
|
||||
p?.setVolume(volume, Track.Source.ScreenShareAudio),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
local: false,
|
||||
videoEnabled$: scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
|
||||
),
|
||||
audioEnabled$: scope.behavior(
|
||||
inputs.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeTrackReference$(p, Track.Source.ScreenShareAudio)
|
||||
: of(null),
|
||||
),
|
||||
map(Boolean),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
82
src/state/media/RemoteUserMediaViewModel.ts
Normal file
82
src/state/media/RemoteUserMediaViewModel.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 { type RemoteParticipant } from "livekit-client";
|
||||
import { combineLatest, map, of, switchMap } from "rxjs";
|
||||
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
|
||||
import {
|
||||
type BaseUserMediaInputs,
|
||||
type BaseUserMediaViewModel,
|
||||
createBaseUserMedia,
|
||||
} from "./UserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
|
||||
export interface RemoteUserMediaViewModel
|
||||
extends BaseUserMediaViewModel, VolumeControls {
|
||||
local: false;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
waitingForMedia$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteUserMediaInputs extends Omit<
|
||||
BaseUserMediaInputs,
|
||||
"statsType"
|
||||
> {
|
||||
participant$: Behavior<RemoteParticipant | null>;
|
||||
pretendToBeDisconnected$: Behavior<boolean>;
|
||||
}
|
||||
|
||||
export function createRemoteUserMedia(
|
||||
scope: ObservableScope,
|
||||
{ pretendToBeDisconnected$, ...inputs }: RemoteUserMediaInputs,
|
||||
): RemoteUserMediaViewModel {
|
||||
const baseUserMedia = createBaseUserMedia(scope, {
|
||||
...inputs,
|
||||
statsType: "inbound-rtp",
|
||||
});
|
||||
|
||||
return {
|
||||
...baseUserMedia,
|
||||
...createVolumeControls(scope, {
|
||||
pretendToBeDisconnected$,
|
||||
sink$: scope.behavior(
|
||||
inputs.participant$.pipe(map((p) => (volume) => p?.setVolume(volume))),
|
||||
),
|
||||
}),
|
||||
local: false,
|
||||
speaking$: scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : baseUserMedia.speaking$,
|
||||
),
|
||||
),
|
||||
),
|
||||
videoEnabled$: scope.behavior(
|
||||
pretendToBeDisconnected$.pipe(
|
||||
switchMap((disconnected) =>
|
||||
disconnected ? of(false) : baseUserMedia.videoEnabled$,
|
||||
),
|
||||
),
|
||||
),
|
||||
waitingForMedia$: scope.behavior(
|
||||
combineLatest(
|
||||
[inputs.livekitRoom$, inputs.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,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
51
src/state/media/ScreenShareViewModel.ts
Normal file
51
src/state/media/ScreenShareViewModel.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 { Track } from "livekit-client";
|
||||
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { type LocalScreenShareViewModel } from "./LocalScreenShareViewModel";
|
||||
import {
|
||||
createMemberMedia,
|
||||
type MemberMediaInputs,
|
||||
type MemberMediaViewModel,
|
||||
} from "./MemberMediaViewModel";
|
||||
import { type RemoteScreenShareViewModel } from "./RemoteScreenShareViewModel";
|
||||
|
||||
/**
|
||||
* A participant's screen share media.
|
||||
*/
|
||||
export type ScreenShareViewModel =
|
||||
| LocalScreenShareViewModel
|
||||
| RemoteScreenShareViewModel;
|
||||
|
||||
/**
|
||||
* Properties which are common to all ScreenShareViewModels.
|
||||
*/
|
||||
export interface BaseScreenShareViewModel extends MemberMediaViewModel {
|
||||
type: "screen share";
|
||||
}
|
||||
|
||||
export type BaseScreenShareInputs = Omit<
|
||||
MemberMediaInputs,
|
||||
"audioSource" | "videoSource"
|
||||
>;
|
||||
|
||||
export function createBaseScreenShare(
|
||||
scope: ObservableScope,
|
||||
inputs: BaseScreenShareInputs,
|
||||
): BaseScreenShareViewModel {
|
||||
return {
|
||||
...createMemberMedia(scope, {
|
||||
...inputs,
|
||||
audioSource: Track.Source.ScreenShareAudio,
|
||||
videoSource: Track.Source.ScreenShare,
|
||||
}),
|
||||
type: "screen share",
|
||||
};
|
||||
}
|
||||
164
src/state/media/UserMediaViewModel.ts
Normal file
164
src/state/media/UserMediaViewModel.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
observeParticipantEvents,
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import { ParticipantEvent, Track } from "livekit-client";
|
||||
|
||||
import { type ReactionOption } from "../../reactions";
|
||||
import { type Behavior } from "../Behavior";
|
||||
import { type LocalUserMediaViewModel } from "./LocalUserMediaViewModel";
|
||||
import {
|
||||
createMemberMedia,
|
||||
type MemberMediaInputs,
|
||||
type MemberMediaViewModel,
|
||||
} from "./MemberMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
|
||||
import { type ObservableScope } from "../ObservableScope";
|
||||
import { showConnectionStats } from "../../settings/settings";
|
||||
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
|
||||
import { videoFit$, videoSizeFromParticipant$ } from "../../utils/videoFit.ts";
|
||||
|
||||
/**
|
||||
* A participant's user media (i.e. their microphone and camera feed).
|
||||
*/
|
||||
export type UserMediaViewModel =
|
||||
| LocalUserMediaViewModel
|
||||
| RemoteUserMediaViewModel;
|
||||
|
||||
export interface BaseUserMediaViewModel extends MemberMediaViewModel {
|
||||
type: "user";
|
||||
speaking$: Behavior<boolean>;
|
||||
audioEnabled$: Behavior<boolean>;
|
||||
videoEnabled$: Behavior<boolean>;
|
||||
videoFit$: Behavior<"cover" | "contain">;
|
||||
toggleCropVideo: () => void;
|
||||
/**
|
||||
* The expected identity of the LiveKit participant. Exposed for debugging.
|
||||
*/
|
||||
rtcBackendIdentity: string;
|
||||
handRaised$: Behavior<Date | null>;
|
||||
reaction$: Behavior<ReactionOption | null>;
|
||||
audioStreamStats$: Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
videoStreamStats$: Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
/**
|
||||
* Set the target dimensions of the HTML element (final dimension after anim).
|
||||
* This can be used to determine the best video fit (fit to frame / keep ratio).
|
||||
* @param targetWidth - The target width of the HTML element displaying the video.
|
||||
* @param targetHeight - The target height of the HTML element displaying the video.
|
||||
*/
|
||||
setTargetDimensions: (targetWidth: number, targetHeight: number) => void;
|
||||
}
|
||||
|
||||
export interface BaseUserMediaInputs extends Omit<
|
||||
MemberMediaInputs,
|
||||
"audioSource" | "videoSource"
|
||||
> {
|
||||
rtcBackendIdentity: string;
|
||||
handRaised$: Behavior<Date | null>;
|
||||
reaction$: Behavior<ReactionOption | null>;
|
||||
statsType: "inbound-rtp" | "outbound-rtp";
|
||||
}
|
||||
|
||||
export function createBaseUserMedia(
|
||||
scope: ObservableScope,
|
||||
{
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
statsType,
|
||||
...inputs
|
||||
}: BaseUserMediaInputs,
|
||||
): BaseUserMediaViewModel {
|
||||
const { participant$ } = inputs;
|
||||
const media$ = scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
|
||||
),
|
||||
);
|
||||
const toggleCropVideo$ = new Subject<void>();
|
||||
|
||||
// The target size of the video element, used to determine the best video fit.
|
||||
// The target size is the final size of the HTML element after any animations have completed.
|
||||
const targetSize$ = new BehaviorSubject<
|
||||
{ width: number; height: number } | undefined
|
||||
>(undefined);
|
||||
|
||||
return {
|
||||
...createMemberMedia(scope, {
|
||||
...inputs,
|
||||
audioSource: Track.Source.Microphone,
|
||||
videoSource: Track.Source.Camera,
|
||||
}),
|
||||
type: "user",
|
||||
speaking$: scope.behavior(
|
||||
participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
? observeParticipantEvents(
|
||||
p,
|
||||
ParticipantEvent.IsSpeakingChanged,
|
||||
).pipe(map((p) => p.isSpeaking))
|
||||
: of(false),
|
||||
),
|
||||
),
|
||||
),
|
||||
audioEnabled$: scope.behavior(
|
||||
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
|
||||
),
|
||||
videoEnabled$: scope.behavior(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
),
|
||||
videoFit$: videoFit$(
|
||||
scope,
|
||||
videoSizeFromParticipant$(participant$),
|
||||
targetSize$,
|
||||
),
|
||||
toggleCropVideo: () => toggleCropVideo$.next(),
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
reaction$,
|
||||
audioStreamStats$: combineLatest([
|
||||
participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
//
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeRtpStreamStats$(p, Track.Source.Microphone, statsType);
|
||||
}),
|
||||
),
|
||||
videoStreamStats$: combineLatest([
|
||||
participant$,
|
||||
showConnectionStats.value$,
|
||||
]).pipe(
|
||||
switchMap(([p, showConnectionStats]) => {
|
||||
if (!p || !showConnectionStats) return of(undefined);
|
||||
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
||||
}),
|
||||
),
|
||||
setTargetDimensions: (targetWidth: number, targetHeight: number): void => {
|
||||
targetSize$.next({ width: targetWidth, height: targetHeight });
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/state/media/observeRtpStreamStats.ts
Normal file
78
src/state/media/observeRtpStreamStats.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 {
|
||||
LocalTrack,
|
||||
type Participant,
|
||||
RemoteTrack,
|
||||
type Track,
|
||||
} from "livekit-client";
|
||||
import {
|
||||
combineLatest,
|
||||
interval,
|
||||
type Observable,
|
||||
startWith,
|
||||
switchMap,
|
||||
map,
|
||||
} from "rxjs";
|
||||
|
||||
import { observeTrackReference$ } from "../observeTrackReference";
|
||||
|
||||
export function observeRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
type: "inbound-rtp" | "outbound-rtp",
|
||||
): Observable<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(participant, source),
|
||||
interval(1000).pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
const track = trackReference?.publication?.track;
|
||||
if (
|
||||
!track ||
|
||||
!(track instanceof RemoteTrack || track instanceof LocalTrack)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const report = await track.getRTCStatsReport();
|
||||
if (!report) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const v of report.values()) {
|
||||
if (v.type === type) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}),
|
||||
startWith(undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeInboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCInboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "inbound-rtp").pipe(
|
||||
map((x) => x as RTCInboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function observeOutboundRtpStreamStats$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<RTCOutboundRtpStreamStats | undefined> {
|
||||
return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe(
|
||||
map((x) => x as RTCOutboundRtpStreamStats | undefined),
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import { withTestScheduler } from "../utils/test";
|
||||
import { withTestScheduler } from "../../utils/test";
|
||||
import { observeSpeaker$ } from "./observeSpeaker";
|
||||
|
||||
const yesNo = {
|
||||
28
src/state/observeTrackReference.ts
Normal file
28
src/state/observeTrackReference.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
Copyright 2026 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 {
|
||||
observeParticipantMedia,
|
||||
type TrackReference,
|
||||
} from "@livekit/components-core";
|
||||
import { type Participant, type Track } from "livekit-client";
|
||||
import { distinctUntilChanged, map, type Observable } from "rxjs";
|
||||
|
||||
/**
|
||||
* Reactively reads a participant's track reference for a given media source.
|
||||
*/
|
||||
export function observeTrackReference$(
|
||||
participant: Participant,
|
||||
source: Track.Source,
|
||||
): Observable<TrackReference | undefined> {
|
||||
return observeParticipantMedia(participant).pipe(
|
||||
map(() => participant.getTrackPublication(source)),
|
||||
distinctUntilChanged(),
|
||||
map((publication) => publication && { participant, publication, source }),
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { GridTile } from "./GridTile";
|
||||
import {
|
||||
mockRtcMembership,
|
||||
createRemoteMedia,
|
||||
mockRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
} from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
@@ -29,7 +29,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
const vm = createRemoteMedia(
|
||||
const vm = mockRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
VolumeOffIcon,
|
||||
VisibilityOnIcon,
|
||||
UserProfileIcon,
|
||||
ExpandIcon,
|
||||
VolumeOffSolidIcon,
|
||||
SwitchCameraSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
@@ -39,11 +39,6 @@ import {
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import styles from "./GridTile.module.css";
|
||||
import {
|
||||
type UserMediaViewModel,
|
||||
LocalUserMediaViewModel,
|
||||
type RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { Slider } from "../Slider";
|
||||
import { MediaView } from "./MediaView";
|
||||
import { useLatest } from "../useLatest";
|
||||
@@ -51,6 +46,9 @@ import { type GridTileViewModel } from "../state/TileViewModel";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -68,7 +66,7 @@ interface TileProps {
|
||||
interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
locallyMuted: boolean;
|
||||
playbackMuted: boolean;
|
||||
waitingForMedia?: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
menuStart?: ReactNode;
|
||||
@@ -79,7 +77,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
playbackMuted,
|
||||
waitingForMedia,
|
||||
primaryButton,
|
||||
menuStart,
|
||||
@@ -89,6 +87,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
displayName,
|
||||
mxcAvatarUrl,
|
||||
focusable,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
...props
|
||||
}) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
@@ -105,24 +105,25 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const speaking = useBehavior(vm.speaking$);
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleFitContain();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const videoFit = useBehavior(vm.videoFit$);
|
||||
|
||||
const rtcBackendIdentity = vm.rtcBackendIdentity;
|
||||
const handRaised = useBehavior(vm.handRaised$);
|
||||
const reaction = useBehavior(vm.reaction$);
|
||||
|
||||
const AudioIcon = locallyMuted
|
||||
// Whenever bounds change, inform the viewModel
|
||||
useEffect(() => {
|
||||
if (targetWidth > 0 && targetHeight > 0) {
|
||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
||||
}
|
||||
}, [targetWidth, targetHeight, vm]);
|
||||
|
||||
const AudioIcon = playbackMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
const audioIconLabel = playbackMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
@@ -132,12 +133,10 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
{/*
|
||||
No additional menu item (used to be the manual fit to frame.
|
||||
Placeholder for future menu items that should be placed here.
|
||||
*/}
|
||||
{menuEnd}
|
||||
</>
|
||||
);
|
||||
@@ -156,7 +155,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionStatus={encryptionStatus}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
videoFit={videoFit}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
[styles.handRaised]: !showSpeaking && handRaised,
|
||||
@@ -166,7 +165,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
data-muted={playbackMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
@@ -202,6 +201,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
rtcBackendIdentity={rtcBackendIdentity}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -245,7 +246,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
playbackMuted={false}
|
||||
mirror={mirror}
|
||||
primaryButton={
|
||||
switchCamera === null ? undefined : (
|
||||
@@ -295,36 +296,31 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const waitingForMedia = useBehavior(vm.waitingForMedia$);
|
||||
const locallyMuted = useBehavior(vm.locallyMuted$);
|
||||
const localVolume = useBehavior(vm.localVolume$);
|
||||
const playbackMuted = useBehavior(vm.playbackMuted$);
|
||||
const playbackVolume = useBehavior(vm.playbackVolume$);
|
||||
const onSelectMute = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleLocallyMuted();
|
||||
vm.togglePlaybackMuted();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const onChangeLocalVolume = useCallback(
|
||||
(v: number) => vm.setLocalVolume(v),
|
||||
[vm],
|
||||
);
|
||||
const onCommitLocalVolume = useCallback(() => vm.commitLocalVolume(), [vm]);
|
||||
|
||||
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
waitingForMedia={waitingForMedia}
|
||||
locallyMuted={locallyMuted}
|
||||
playbackMuted={playbackMuted}
|
||||
mirror={false}
|
||||
menuStart={
|
||||
<>
|
||||
<ToggleMenuItem
|
||||
Icon={MicOffIcon}
|
||||
label={t("video_tile.mute_for_me")}
|
||||
checked={locallyMuted}
|
||||
checked={playbackMuted}
|
||||
onSelect={onSelectMute}
|
||||
/>
|
||||
{/* TODO: Figure out how to make this slider keyboard accessible */}
|
||||
@@ -332,9 +328,9 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
<Slider
|
||||
className={styles.volumeSlider}
|
||||
label={t("video_tile.volume")}
|
||||
value={localVolume}
|
||||
onValueChange={onChangeLocalVolume}
|
||||
onValueCommit={onCommitLocalVolume}
|
||||
value={playbackVolume}
|
||||
onValueChange={vm.adjustPlaybackVolume}
|
||||
onValueCommit={vm.commitPlaybackVolume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
@@ -374,7 +370,7 @@ export const GridTile: FC<GridTileProps> = ({
|
||||
const displayName = useBehavior(media.displayName$);
|
||||
const mxcAvatarUrl = useBehavior(media.mxcAvatarUrl$);
|
||||
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
if (media.local) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TrackInfo } from "@livekit/protocol";
|
||||
import { type ComponentProps } from "react";
|
||||
|
||||
import { MediaView } from "./MediaView";
|
||||
import { EncryptionStatus } from "../state/MediaViewModel";
|
||||
import { EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
||||
import { mockLocalParticipant } from "../utils/test";
|
||||
|
||||
describe("MediaView", () => {
|
||||
|
||||
@@ -16,10 +16,10 @@ import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/ico
|
||||
|
||||
import styles from "./MediaView.module.css";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { type EncryptionStatus } from "../state/MediaViewModel";
|
||||
import { type EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
||||
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
||||
import {
|
||||
showConnectionStats,
|
||||
showConnectionStats as showConnectionStatsSetting,
|
||||
showHandRaisedTimer,
|
||||
useSetting,
|
||||
} from "../settings/settings";
|
||||
@@ -85,7 +85,7 @@ export const MediaView: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||
const [showConnectioStats] = useSetting(showConnectionStats);
|
||||
const [showConnectionStats] = useSetting(showConnectionStatsSetting);
|
||||
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
|
||||
@@ -139,10 +139,10 @@ export const MediaView: FC<Props> = ({
|
||||
{waitingForMedia && (
|
||||
<div className={styles.status}>
|
||||
{t("video_tile.waiting_for_media")}
|
||||
{showConnectioStats ? " " + rtcBackendIdentity : ""}
|
||||
{showConnectionStats ? " " + rtcBackendIdentity : ""}
|
||||
</div>
|
||||
)}
|
||||
{(audioStreamStats || videoStreamStats) && (
|
||||
{showConnectionStats && (
|
||||
<>
|
||||
<RTCConnectionStats
|
||||
audio={audioStreamStats}
|
||||
|
||||
@@ -84,7 +84,6 @@ Please see LICENSE in the repository root for full details.
|
||||
.expand {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
padding: var(--cpd-space-2x);
|
||||
border: none;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
@@ -108,6 +107,35 @@ Please see LICENSE in the repository root for full details.
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
width: 100%;
|
||||
min-width: 172px;
|
||||
}
|
||||
|
||||
/* Disable the hover effect for the screen share volume menu button */
|
||||
.volumeMenuItem:hover {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.volumeMenuItem {
|
||||
gap: var(--cpd-space-3x);
|
||||
}
|
||||
|
||||
.menuMuteButton {
|
||||
appearance: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Make icons change color with the theme */
|
||||
.menuMuteButton > svg {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.expand > svg {
|
||||
display: block;
|
||||
color: var(--cpd-color-icon-primary);
|
||||
@@ -119,17 +147,22 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
.expand:active {
|
||||
.expand:active,
|
||||
.expand[data-state="open"] {
|
||||
background: var(--cpd-color-gray-100);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.tile > div > button {
|
||||
opacity: 0;
|
||||
}
|
||||
.tile:hover > div > button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tile:has(:focus-visible) > div > button {
|
||||
.tile:has(:focus-visible) > div > button,
|
||||
.tile > div:has([data-state="open"]) > button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,17 @@ import { test, expect, vi } from "vitest";
|
||||
import { isInaccessible, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
createLocalMedia,
|
||||
createRemoteMedia,
|
||||
mockLocalMedia,
|
||||
mockRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
mockRemoteScreenShare,
|
||||
} from "../utils/test";
|
||||
import { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { constant } from "../state/Behavior";
|
||||
@@ -28,7 +30,7 @@ global.IntersectionObserver = class MockIntersectionObserver {
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("SpotlightTile is accessible", async () => {
|
||||
const vm1 = createRemoteMedia(
|
||||
const vm1 = mockRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
rawDisplayName: "Alice",
|
||||
@@ -37,7 +39,7 @@ test("SpotlightTile is accessible", async () => {
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
const vm2 = createLocalMedia(
|
||||
const vm2 = mockLocalMedia(
|
||||
mockRtcMembership("@bob:example.org", "BBBB"),
|
||||
{
|
||||
rawDisplayName: "Bob",
|
||||
@@ -78,3 +80,63 @@ test("SpotlightTile is accessible", async () => {
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(toggleExpanded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Screen share volume UI is shown when screen share has audio", async () => {
|
||||
const vm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(true));
|
||||
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
focusable
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
|
||||
// Volume menu button should exist
|
||||
expect(screen.queryByRole("button", { name: /volume/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("Screen share volume UI is hidden when screen share has no audio", async () => {
|
||||
const vm = mockRemoteScreenShare(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{},
|
||||
mockRemoteParticipant({}),
|
||||
);
|
||||
|
||||
vi.spyOn(vm, "audioEnabled$", "get").mockReturnValue(constant(false));
|
||||
|
||||
const toggleExpanded = vi.fn();
|
||||
const { container } = render(
|
||||
<SpotlightTile
|
||||
vm={new SpotlightTileViewModel(constant([vm]), constant(false))}
|
||||
targetWidth={300}
|
||||
targetHeight={200}
|
||||
expanded={false}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
showIndicators
|
||||
focusable
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
|
||||
// Volume menu button should not exist
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /volume/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
CollapseIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
VolumeOffIcon,
|
||||
VolumeOnIcon,
|
||||
VolumeOffSolidIcon,
|
||||
VolumeOnSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { type Observable, map } from "rxjs";
|
||||
@@ -27,25 +31,27 @@ import { useObservableRef } from "observable-hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { Menu, MenuItem } from "@vector-im/compound-web";
|
||||
|
||||
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
||||
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
||||
import { MediaView } from "./MediaView";
|
||||
import styles from "./SpotlightTile.module.css";
|
||||
import {
|
||||
type EncryptionStatus,
|
||||
LocalUserMediaViewModel,
|
||||
type MediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
type UserMediaViewModel,
|
||||
type RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type EncryptionStatus } from "../state/media/MemberMediaViewModel";
|
||||
import { type LocalUserMediaViewModel } from "../state/media/LocalUserMediaViewModel";
|
||||
import { type RemoteUserMediaViewModel } from "../state/media/RemoteUserMediaViewModel";
|
||||
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
|
||||
import { type ScreenShareViewModel } from "../state/media/ScreenShareViewModel";
|
||||
import { type RemoteScreenShareViewModel } from "../state/media/RemoteScreenShareViewModel";
|
||||
import { type MediaViewModel } from "../state/media/MediaViewModel";
|
||||
import { Slider } from "../Slider";
|
||||
import { platform } from "../Platform";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -54,7 +60,6 @@ interface SpotlightItemBaseProps {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
videoEnabled: boolean;
|
||||
userId: string;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionStatus: EncryptionStatus;
|
||||
@@ -67,6 +72,7 @@ interface SpotlightItemBaseProps {
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
videoFit: "contain" | "cover";
|
||||
videoEnabled: boolean;
|
||||
}
|
||||
|
||||
interface SpotlightLocalUserMediaItemProps extends SpotlightUserMediaItemBaseProps {
|
||||
@@ -105,15 +111,17 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const cropVideo = useBehavior(vm.cropVideo$);
|
||||
const videoFit = useBehavior(vm.videoFit$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
videoFit: cropVideo ? "cover" : "contain",
|
||||
videoFit,
|
||||
videoEnabled,
|
||||
...props,
|
||||
};
|
||||
|
||||
return vm instanceof LocalUserMediaViewModel ? (
|
||||
return vm.local ? (
|
||||
<SpotlightLocalUserMediaItem vm={vm} {...baseProps} />
|
||||
) : (
|
||||
<SpotlightRemoteUserMediaItem vm={vm} {...baseProps} />
|
||||
@@ -122,10 +130,41 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
|
||||
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
|
||||
|
||||
interface SpotlightScreenShareItemProps extends SpotlightItemBaseProps {
|
||||
vm: ScreenShareViewModel;
|
||||
videoEnabled: boolean;
|
||||
}
|
||||
|
||||
const SpotlightScreenShareItem: FC<SpotlightScreenShareItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
return <MediaView videoFit="contain" mirror={false} {...props} />;
|
||||
};
|
||||
|
||||
interface SpotlightRemoteScreenShareItemProps extends SpotlightItemBaseProps {
|
||||
vm: RemoteScreenShareViewModel;
|
||||
}
|
||||
|
||||
const SpotlightRemoteScreenShareItem: FC<
|
||||
SpotlightRemoteScreenShareItemProps
|
||||
> = ({ vm, ...props }) => {
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
return (
|
||||
<SpotlightScreenShareItem vm={vm} videoEnabled={videoEnabled} {...props} />
|
||||
);
|
||||
};
|
||||
|
||||
interface SpotlightItemProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: MediaViewModel;
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetWidth: number;
|
||||
/**
|
||||
* The height this tile will have once its animations have settled.
|
||||
*/
|
||||
targetHeight: number;
|
||||
focusable: boolean;
|
||||
intersectionObserver$: Observable<IntersectionObserver>;
|
||||
@@ -147,12 +186,21 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
"aria-hidden": ariaHidden,
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Whenever target bounds change, inform the viewModel
|
||||
useEffect(() => {
|
||||
if (targetWidth > 0 && targetHeight > 0) {
|
||||
if (vm.type != "screen share") {
|
||||
vm.setTargetDimensions(targetWidth, targetHeight);
|
||||
}
|
||||
}
|
||||
}, [targetWidth, targetHeight, vm]);
|
||||
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const focusUrl = useBehavior(vm.focusUrl$);
|
||||
const displayName = useBehavior(vm.displayName$);
|
||||
const mxcAvatarUrl = useBehavior(vm.mxcAvatarUrl$);
|
||||
const video = useBehavior(vm.video$);
|
||||
const videoEnabled = useBehavior(vm.videoEnabled$);
|
||||
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useBehavior(vm.encryptionStatus$);
|
||||
|
||||
@@ -178,7 +226,6 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video: video ?? undefined,
|
||||
videoEnabled,
|
||||
userId: vm.userId,
|
||||
unencryptedWarning,
|
||||
focusUrl,
|
||||
@@ -189,15 +236,84 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
"aria-hidden": ariaHidden,
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView videoFit="contain" mirror={false} {...baseProps} />
|
||||
if (vm.type === "user")
|
||||
return <SpotlightUserMediaItem vm={vm} {...baseProps} />;
|
||||
return vm.local ? (
|
||||
<SpotlightScreenShareItem vm={vm} videoEnabled {...baseProps} />
|
||||
) : (
|
||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||
<SpotlightRemoteScreenShareItem vm={vm} {...baseProps} />
|
||||
);
|
||||
};
|
||||
|
||||
SpotlightItem.displayName = "SpotlightItem";
|
||||
|
||||
interface ScreenShareVolumeButtonProps {
|
||||
vm: RemoteScreenShareViewModel;
|
||||
}
|
||||
|
||||
const ScreenShareVolumeButton: FC<ScreenShareVolumeButtonProps> = ({ vm }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
const playbackMuted = useBehavior(vm.playbackMuted$);
|
||||
const playbackVolume = useBehavior(vm.playbackVolume$);
|
||||
|
||||
const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;
|
||||
const VolumeSolidIcon = playbackMuted
|
||||
? VolumeOffSolidIcon
|
||||
: VolumeOnSolidIcon;
|
||||
|
||||
const [volumeMenuOpen, setVolumeMenuOpen] = useState(false);
|
||||
const onMuteButtonClick = useCallback(() => vm.togglePlaybackMuted(), [vm]);
|
||||
const onVolumeChange = useCallback(
|
||||
(v: number) => vm.adjustPlaybackVolume(v),
|
||||
[vm],
|
||||
);
|
||||
const onVolumeCommit = useCallback(() => vm.commitPlaybackVolume(), [vm]);
|
||||
|
||||
return (
|
||||
audioEnabled && (
|
||||
<Menu
|
||||
open={volumeMenuOpen}
|
||||
onOpenChange={setVolumeMenuOpen}
|
||||
title={t("video_tile.screen_share_volume")}
|
||||
side="top"
|
||||
align="end"
|
||||
trigger={
|
||||
<button
|
||||
className={styles.expand}
|
||||
aria-label={t("video_tile.screen_share_volume")}
|
||||
>
|
||||
<VolumeSolidIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
as="div"
|
||||
className={styles.volumeMenuItem}
|
||||
onSelect={null}
|
||||
label={null}
|
||||
hideChevron={true}
|
||||
>
|
||||
<button className={styles.menuMuteButton} onClick={onMuteButtonClick}>
|
||||
<VolumeIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
<Slider
|
||||
className={styles.volumeSlider}
|
||||
label={t("video_tile.volume")}
|
||||
value={playbackVolume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onValueChange={onVolumeChange}
|
||||
onValueCommit={onVolumeCommit}
|
||||
/>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: SpotlightTileViewModel;
|
||||
@@ -232,6 +348,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||
const visibleMedia = media.at(visibleIndex);
|
||||
const canGoBack = visibleIndex > 0;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||
|
||||
@@ -339,16 +456,21 @@ export const SpotlightTile: FC<Props> = ({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.bottomRightButtons}>
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={"maximise"}
|
||||
onClick={onToggleFullscreen}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
|
||||
<div className={styles.bottomRightButtons}>
|
||||
{visibleMedia?.type === "screen share" && !visibleMedia.local && (
|
||||
<ScreenShareVolumeButton vm={visibleMedia} />
|
||||
)}
|
||||
{platform === "desktop" && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={"maximise"}
|
||||
onClick={onToggleFullscreen}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
|
||||
@@ -47,6 +47,7 @@ test("generateItems", () => {
|
||||
expectObservable(
|
||||
hot<string>(inputMarbles).pipe(
|
||||
generateItems(
|
||||
"test items",
|
||||
function* (input) {
|
||||
for (let i = 1; i <= +input; i++) {
|
||||
yield { keys: [i], data: undefined };
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type OperatorFunction,
|
||||
distinctUntilChanged,
|
||||
} from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { type Behavior } from "../state/Behavior";
|
||||
import { Epoch, ObservableScope } from "../state/ObservableScope";
|
||||
@@ -60,6 +61,20 @@ export function accumulate<State, Event>(
|
||||
events$.pipe(scan(update, initial), startWith(initial));
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a source of toggle events, creates a Behavior whose value toggles
|
||||
* between `true` and `false`.
|
||||
*/
|
||||
export function createToggle$(
|
||||
scope: ObservableScope,
|
||||
initialValue: boolean,
|
||||
toggle$: Observable<void>,
|
||||
): Behavior<boolean> {
|
||||
return scope.behavior(
|
||||
toggle$.pipe(accumulate(initialValue, (state) => !state)),
|
||||
);
|
||||
}
|
||||
|
||||
const switchSymbol = Symbol("switch");
|
||||
|
||||
/**
|
||||
@@ -122,8 +137,9 @@ export function pauseWhen<T>(pause$: Behavior<boolean>) {
|
||||
);
|
||||
}
|
||||
|
||||
interface ItemHandle<Data, Item> {
|
||||
interface ItemHandle<Keys extends unknown[], Data, Item> {
|
||||
scope: ObservableScope;
|
||||
keys: readonly [...Keys];
|
||||
data$: BehaviorSubject<Data>;
|
||||
item: Item;
|
||||
}
|
||||
@@ -135,6 +151,7 @@ interface ItemHandle<Data, Item> {
|
||||
* requested at a later time, and destroyed (have their scope ended) when the
|
||||
* key is no longer requested.
|
||||
*
|
||||
* @param name A name for this collection to use in debug logs.
|
||||
* @param generator A generator function yielding a tuple of keys and the
|
||||
* currently associated data for each item that it wants to exist.
|
||||
* @param factory A function constructing an individual item, given the item's key,
|
||||
@@ -146,16 +163,17 @@ export function generateItems<
|
||||
Data,
|
||||
Item,
|
||||
>(
|
||||
name: string,
|
||||
generator: (
|
||||
input: Input,
|
||||
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||
factory: (
|
||||
scope: ObservableScope,
|
||||
data$: Behavior<Data>,
|
||||
...keys: Keys
|
||||
) => Item,
|
||||
): OperatorFunction<Input, Item[]> {
|
||||
return generateItemsInternal(generator, factory, (items) => items);
|
||||
return generateItemsInternal(name, generator, factory, (items) => items);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,9 +185,10 @@ export function generateItemsWithEpoch<
|
||||
Data,
|
||||
Item,
|
||||
>(
|
||||
name: string,
|
||||
generator: (
|
||||
input: Input,
|
||||
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||
factory: (
|
||||
scope: ObservableScope,
|
||||
data$: Behavior<Data>,
|
||||
@@ -177,6 +196,7 @@ export function generateItemsWithEpoch<
|
||||
) => Item,
|
||||
): OperatorFunction<Epoch<Input>, Epoch<Item[]>> {
|
||||
return generateItemsInternal(
|
||||
name,
|
||||
function* (input) {
|
||||
yield* generator(input.value);
|
||||
},
|
||||
@@ -207,6 +227,38 @@ export function filterBehavior<T, S extends T>(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a changing input value to an item whose lifetime is tied to a certain
|
||||
* computed key. The item may capture some dynamic data from the input.
|
||||
*/
|
||||
export function generateItem<
|
||||
Input,
|
||||
Keys extends [unknown, ...unknown[]],
|
||||
Data,
|
||||
Item,
|
||||
>(
|
||||
name: string,
|
||||
generator: (input: Input) => { keys: readonly [...Keys]; data: Data },
|
||||
factory: (
|
||||
scope: ObservableScope,
|
||||
data$: Behavior<Data>,
|
||||
...keys: Keys
|
||||
) => Item,
|
||||
): OperatorFunction<Input, Item> {
|
||||
return (input$) =>
|
||||
input$.pipe(
|
||||
generateItemsInternal(
|
||||
name,
|
||||
function* (input) {
|
||||
yield generator(input);
|
||||
},
|
||||
factory,
|
||||
(items) => items,
|
||||
),
|
||||
map(([item]) => item),
|
||||
);
|
||||
}
|
||||
|
||||
function generateItemsInternal<
|
||||
Input,
|
||||
Keys extends [unknown, ...unknown[]],
|
||||
@@ -214,9 +266,10 @@ function generateItemsInternal<
|
||||
Item,
|
||||
Output,
|
||||
>(
|
||||
name: string,
|
||||
generator: (
|
||||
input: Input,
|
||||
) => Generator<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||
) => Iterable<{ keys: readonly [...Keys]; data: Data }, void, void>,
|
||||
factory: (
|
||||
scope: ObservableScope,
|
||||
data$: Behavior<Data>,
|
||||
@@ -232,26 +285,34 @@ function generateItemsInternal<
|
||||
Input,
|
||||
{
|
||||
map: Map<any, any>;
|
||||
items: Set<ItemHandle<Data, Item>>;
|
||||
items: Set<ItemHandle<Keys, Data, Item>>;
|
||||
input: Input;
|
||||
},
|
||||
{ map: Map<any, any>; items: Set<ItemHandle<Data, Item>> }
|
||||
{ map: Map<any, any>; items: Set<ItemHandle<Keys, Data, Item>> }
|
||||
>(
|
||||
({ map: prevMap, items: prevItems }, input) => {
|
||||
const nextMap = new Map();
|
||||
const nextItems = new Set<ItemHandle<Data, Item>>();
|
||||
const nextItems = new Set<ItemHandle<Keys, Data, Item>>();
|
||||
|
||||
for (const { keys, data } of generator(input)) {
|
||||
// Disable type checks for a second to grab the item out of a nested map
|
||||
let i: any = prevMap;
|
||||
for (const key of keys) i = i?.get(key);
|
||||
let item = i as ItemHandle<Data, Item> | undefined;
|
||||
let item = i as ItemHandle<Keys, Data, Item> | undefined;
|
||||
|
||||
if (item === undefined) {
|
||||
// First time requesting the key; create the item
|
||||
const scope = new ObservableScope();
|
||||
const data$ = new BehaviorSubject(data);
|
||||
item = { scope, data$, item: factory(scope, data$, ...keys) };
|
||||
logger.debug(
|
||||
`[${name}] Creating item with keys ${keys.join(", ")}`,
|
||||
);
|
||||
item = {
|
||||
scope,
|
||||
keys,
|
||||
data$,
|
||||
item: factory(scope, data$, ...keys),
|
||||
};
|
||||
} else {
|
||||
item.data$.next(data);
|
||||
}
|
||||
@@ -269,7 +330,7 @@ function generateItemsInternal<
|
||||
const finalKey = keys[keys.length - 1];
|
||||
if (m.has(finalKey))
|
||||
throw new Error(
|
||||
`Keys must be unique (tried to generate multiple items for key ${keys})`,
|
||||
`Keys must be unique (tried to generate multiple items for key ${keys.join(", ")})`,
|
||||
);
|
||||
m.set(keys[keys.length - 1], item);
|
||||
nextItems.add(item);
|
||||
@@ -277,7 +338,12 @@ function generateItemsInternal<
|
||||
|
||||
// Destroy all items that are no longer being requested
|
||||
for (const item of prevItems)
|
||||
if (!nextItems.has(item)) item.scope.end();
|
||||
if (!nextItems.has(item)) {
|
||||
logger.debug(
|
||||
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
|
||||
);
|
||||
item.scope.end();
|
||||
}
|
||||
|
||||
return { map: nextMap, items: nextItems, input };
|
||||
},
|
||||
@@ -285,7 +351,15 @@ function generateItemsInternal<
|
||||
),
|
||||
finalizeValue(({ items }) => {
|
||||
// Destroy all remaining items when no longer subscribed
|
||||
for (const { scope } of items) scope.end();
|
||||
logger.debug(
|
||||
`[${name}] End of scope, destroying all ${items.size} items…`,
|
||||
);
|
||||
for (const item of items) {
|
||||
logger.debug(
|
||||
`[${name}] Destroying item with keys ${item.keys.join(", ")}`,
|
||||
);
|
||||
item.scope.end();
|
||||
}
|
||||
}),
|
||||
map(({ items, input }) =>
|
||||
project(
|
||||
|
||||
@@ -52,10 +52,6 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc/IKeyTransport";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
RemoteUserMediaViewModel,
|
||||
} from "../state/MediaViewModel";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
@@ -66,6 +62,18 @@ import { type MediaDevices } from "../state/MediaDevices";
|
||||
import { type Behavior, constant } from "../state/Behavior";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
import { MuteStates } from "../state/MuteStates";
|
||||
import {
|
||||
createLocalUserMedia,
|
||||
type LocalUserMediaViewModel,
|
||||
} from "../state/media/LocalUserMediaViewModel";
|
||||
import {
|
||||
createRemoteUserMedia,
|
||||
type RemoteUserMediaViewModel,
|
||||
} from "../state/media/RemoteUserMediaViewModel";
|
||||
import {
|
||||
createRemoteScreenShare,
|
||||
type RemoteScreenShareViewModel,
|
||||
} from "../state/media/RemoteScreenShareViewModel";
|
||||
|
||||
export function withFakeTimers(continuation: () => void): void {
|
||||
vi.useFakeTimers();
|
||||
@@ -323,30 +331,27 @@ export function mockLocalParticipant(
|
||||
} as Partial<LocalParticipant> as LocalParticipant;
|
||||
}
|
||||
|
||||
export function createLocalMedia(
|
||||
export function mockLocalMedia(
|
||||
rtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
localParticipant: LocalParticipant,
|
||||
mediaDevices: MediaDevices,
|
||||
): LocalUserMediaViewModel {
|
||||
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||
return new LocalUserMediaViewModel(
|
||||
testScope(),
|
||||
"local",
|
||||
member.userId,
|
||||
rtcMember.rtcBackendIdentity,
|
||||
constant(localParticipant),
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
constant(mockLivekitRoom({ localParticipant })),
|
||||
constant("https://rtc-example.org"),
|
||||
return createLocalUserMedia(testScope(), {
|
||||
id: "local",
|
||||
userId: member.userId,
|
||||
rtcBackendIdentity: rtcMember.rtcBackendIdentity,
|
||||
participant$: constant(localParticipant),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
livekitRoom$: constant(mockLivekitRoom({ localParticipant })),
|
||||
focusUrl$: constant("https://rtc-example.org"),
|
||||
mediaDevices,
|
||||
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||
constant(member.getMxcAvatarUrl()),
|
||||
constant(null),
|
||||
constant(null),
|
||||
);
|
||||
displayName$: constant(member.rawDisplayName ?? "nodisplayname"),
|
||||
mxcAvatarUrl$: constant(member.getMxcAvatarUrl()),
|
||||
handRaised$: constant(null),
|
||||
reaction$: constant(null),
|
||||
});
|
||||
}
|
||||
|
||||
export function mockRemoteParticipant(
|
||||
@@ -364,7 +369,7 @@ export function mockRemoteParticipant(
|
||||
} as RemoteParticipant;
|
||||
}
|
||||
|
||||
export function createRemoteMedia(
|
||||
export function mockRemoteMedia(
|
||||
rtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
participant: RemoteParticipant | null,
|
||||
@@ -376,23 +381,45 @@ export function createRemoteMedia(
|
||||
),
|
||||
): RemoteUserMediaViewModel {
|
||||
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||
return new RemoteUserMediaViewModel(
|
||||
testScope(),
|
||||
"remote",
|
||||
member.userId,
|
||||
rtcMember.rtcBackendIdentity,
|
||||
constant(participant),
|
||||
return createRemoteUserMedia(testScope(), {
|
||||
id: "remote",
|
||||
userId: member.userId,
|
||||
rtcBackendIdentity: rtcMember.rtcBackendIdentity,
|
||||
participant$: constant(participant),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
livekitRoom$: constant(livekitRoom),
|
||||
focusUrl$: constant("https://rtc-example.org"),
|
||||
pretendToBeDisconnected$: constant(false),
|
||||
displayName$: constant(member.rawDisplayName ?? "nodisplayname"),
|
||||
mxcAvatarUrl$: constant(member.getMxcAvatarUrl()),
|
||||
handRaised$: constant(null),
|
||||
reaction$: constant(null),
|
||||
});
|
||||
}
|
||||
|
||||
export function mockRemoteScreenShare(
|
||||
rtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
participant: RemoteParticipant | null,
|
||||
livekitRoom: LivekitRoom | undefined = mockLivekitRoom(
|
||||
{},
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
remoteParticipants$: of(participant ? [participant] : []),
|
||||
},
|
||||
constant(livekitRoom),
|
||||
constant("https://rtc-example.org"),
|
||||
constant(false),
|
||||
constant(member.rawDisplayName ?? "nodisplayname"),
|
||||
constant(member.getMxcAvatarUrl()),
|
||||
constant(null),
|
||||
constant(null),
|
||||
);
|
||||
),
|
||||
): RemoteScreenShareViewModel {
|
||||
const member = mockMatrixRoomMember(rtcMember, roomMember);
|
||||
return createRemoteScreenShare(testScope(), {
|
||||
id: "screenshare",
|
||||
userId: member.userId,
|
||||
participant$: constant(participant),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
livekitRoom$: constant(livekitRoom),
|
||||
focusUrl$: constant("https://rtc-example.org"),
|
||||
pretendToBeDisconnected$: constant(false),
|
||||
displayName$: constant(member.rawDisplayName ?? "nodisplayname"),
|
||||
mxcAvatarUrl$: constant(member.getMxcAvatarUrl()),
|
||||
});
|
||||
}
|
||||
|
||||
export function mockConfig(
|
||||
|
||||
263
src/utils/videoFit.test.ts
Normal file
263
src/utils/videoFit.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
Copyright 2026 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, expect, test, vi } from "vitest";
|
||||
import {
|
||||
LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
type RemoteTrackPublication,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
import { videoFit$, videoSizeFromParticipant$ } from "./videoFit";
|
||||
import { constant } from "../state/Behavior";
|
||||
import {
|
||||
flushPromises,
|
||||
mockLocalParticipant,
|
||||
mockRemoteParticipant,
|
||||
} from "./test";
|
||||
|
||||
describe("videoFit$ defaults", () => {
|
||||
test.each([
|
||||
{
|
||||
videoSize: { width: 1920, height: 1080 },
|
||||
tileSize: undefined,
|
||||
},
|
||||
{
|
||||
videoSize: { width: 1080, height: 1920 },
|
||||
tileSize: undefined,
|
||||
},
|
||||
{
|
||||
videoSize: undefined,
|
||||
tileSize: { width: 1920, height: 1080 },
|
||||
},
|
||||
{
|
||||
videoSize: undefined,
|
||||
tileSize: { width: 1080, height: 1920 },
|
||||
},
|
||||
])(
|
||||
"videoFit$ returns `cover` when videoSize is $videoSize and tileSize is $tileSize",
|
||||
({ videoSize, tileSize }) => {
|
||||
const scope = new ObservableScope();
|
||||
const videoSize$ = constant(videoSize);
|
||||
const tileSize$ = constant(tileSize);
|
||||
|
||||
const fit = videoFit$(scope, videoSize$, tileSize$);
|
||||
expect(fit.value).toBe("cover");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const VIDEO_480_L = { width: 640, height: 480 };
|
||||
const VIDEO_720_L = { width: 1280, height: 720 };
|
||||
const VIDEO_1080_L = { width: 1920, height: 1080 };
|
||||
|
||||
// Some sizes from real world testing, which don't match the standard video sizes exactly
|
||||
const TILE_SIZE_1_L = { width: 180, height: 135 };
|
||||
const TILE_SIZE_3_P = { width: 379, height: 542 };
|
||||
const TILE_SIZE_4_L = { width: 957, height: 542 };
|
||||
// This is the size of an iPhone Xr in portrait mode
|
||||
const TILE_SIZE_5_P = { width: 414, height: 896 };
|
||||
|
||||
export function invertSize(size: { width: number; height: number }): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
return {
|
||||
width: size.height,
|
||||
height: size.width,
|
||||
};
|
||||
}
|
||||
|
||||
test.each([
|
||||
{
|
||||
videoSize: VIDEO_480_L,
|
||||
tileSize: TILE_SIZE_1_L,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_480_L),
|
||||
tileSize: TILE_SIZE_1_L,
|
||||
expected: "contain",
|
||||
},
|
||||
{
|
||||
videoSize: VIDEO_720_L,
|
||||
tileSize: TILE_SIZE_4_L,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_720_L),
|
||||
tileSize: TILE_SIZE_4_L,
|
||||
expected: "contain",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_1080_L),
|
||||
tileSize: TILE_SIZE_3_P,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: VIDEO_1080_L,
|
||||
tileSize: TILE_SIZE_5_P,
|
||||
expected: "contain",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_1080_L),
|
||||
tileSize: TILE_SIZE_5_P,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
// square video
|
||||
videoSize: { width: 400, height: 400 },
|
||||
tileSize: VIDEO_480_L,
|
||||
expected: "contain",
|
||||
},
|
||||
{
|
||||
// Should default to cover if the initial size is 0:0.
|
||||
// Or else it will cause a flash of "contain" mode until the real size is loaded, which can be jarring.
|
||||
videoSize: VIDEO_480_L,
|
||||
tileSize: { width: 0, height: 0 },
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: { width: 0, height: 0 },
|
||||
tileSize: VIDEO_480_L,
|
||||
expected: "cover",
|
||||
},
|
||||
])(
|
||||
"videoFit$ returns $expected when videoSize is $videoSize and tileSize is $tileSize",
|
||||
({ videoSize, tileSize, expected }) => {
|
||||
const scope = new ObservableScope();
|
||||
const videoSize$ = constant(videoSize);
|
||||
const tileSize$ = constant(tileSize);
|
||||
|
||||
const fit = videoFit$(scope, videoSize$, tileSize$);
|
||||
expect(fit.value).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
describe("extracting video size from participant stats", () => {
|
||||
function createMockRtpStats(
|
||||
isInbound: boolean,
|
||||
props: Partial<RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats> = {},
|
||||
): RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats {
|
||||
const baseStats = {
|
||||
id: "mock-stats-id",
|
||||
timestamp: Date.now(),
|
||||
type: isInbound ? "inbound-rtp" : "outbound-rtp",
|
||||
kind: "video",
|
||||
...props,
|
||||
};
|
||||
|
||||
return baseStats as RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
}
|
||||
|
||||
test("get stats for local user", async () => {
|
||||
const localParticipant = mockLocalParticipant({
|
||||
identity: "@local:example.org:AAAAAA",
|
||||
});
|
||||
|
||||
const mockReport: RTCStatsReport = new Map([
|
||||
[
|
||||
"OT01V639885149",
|
||||
createMockRtpStats(false, {
|
||||
frameWidth: 1280,
|
||||
frameHeight: 720,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const track = {
|
||||
source: Track.Source.Camera,
|
||||
getRTCStatsReport: vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Promise.resolve(mockReport)),
|
||||
} as Partial<LocalTrack> as LocalTrack;
|
||||
|
||||
// Set up the prototype chain (there is an instanceof check in getRTCStatsReport)
|
||||
Object.setPrototypeOf(track, LocalTrack.prototype);
|
||||
|
||||
localParticipant.getTrackPublication = vi
|
||||
.fn()
|
||||
.mockImplementation((source: Track.Source) => {
|
||||
if (source === Track.Source.Camera) {
|
||||
return {
|
||||
track,
|
||||
} as unknown as LocalTrackPublication;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const videoDimensions$ = videoSizeFromParticipant$(
|
||||
constant(localParticipant),
|
||||
);
|
||||
|
||||
const publishedDimensions: { width: number; height: number }[] = [];
|
||||
videoDimensions$.subscribe((dimensions) => {
|
||||
if (dimensions) publishedDimensions.push(dimensions);
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const dimension = publishedDimensions.pop();
|
||||
expect(dimension).toEqual({ width: 1280, height: 720 });
|
||||
});
|
||||
|
||||
test("get stats for remote user", async () => {
|
||||
// vi.useFakeTimers()
|
||||
const remoteParticipant = mockRemoteParticipant({
|
||||
identity: "@bob:example.org:AAAAAA",
|
||||
});
|
||||
|
||||
const mockReport: RTCStatsReport = new Map([
|
||||
[
|
||||
"OT01V639885149",
|
||||
createMockRtpStats(true, {
|
||||
frameWidth: 480,
|
||||
frameHeight: 640,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const track = {
|
||||
source: Track.Source.Camera,
|
||||
getRTCStatsReport: vi
|
||||
.fn()
|
||||
.mockImplementation(async () => Promise.resolve(mockReport)),
|
||||
} as Partial<LocalTrack> as LocalTrack;
|
||||
|
||||
// Set up the prototype chain (there is an instanceof check in getRTCStatsReport)
|
||||
Object.setPrototypeOf(track, LocalTrack.prototype);
|
||||
|
||||
remoteParticipant.getTrackPublication = vi
|
||||
.fn()
|
||||
.mockImplementation((source: Track.Source) => {
|
||||
if (source === Track.Source.Camera) {
|
||||
return {
|
||||
track,
|
||||
} as unknown as RemoteTrackPublication;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const videoDimensions$ = videoSizeFromParticipant$(
|
||||
constant(remoteParticipant),
|
||||
);
|
||||
|
||||
const publishedDimensions: { width: number; height: number }[] = [];
|
||||
videoDimensions$.subscribe((dimensions) => {
|
||||
if (dimensions) publishedDimensions.push(dimensions);
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const dimension = publishedDimensions.pop();
|
||||
expect(dimension).toEqual({ width: 480, height: 640 });
|
||||
});
|
||||
});
|
||||
111
src/utils/videoFit.ts
Normal file
111
src/utils/videoFit.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
Copyright 2026 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, type Observable, of, switchMap } from "rxjs";
|
||||
import {
|
||||
type LocalParticipant,
|
||||
type RemoteParticipant,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
|
||||
import { type ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { type Behavior } from "../state/Behavior.ts";
|
||||
import {
|
||||
observeInboundRtpStreamStats$,
|
||||
observeOutboundRtpStreamStats$,
|
||||
} from "../state/media/observeRtpStreamStats";
|
||||
|
||||
type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the appropriate video fit mode ("cover" or "contain") based on the aspect ratios of the video and the tile.
|
||||
* - If the video and tile have the same orientation (both landscape or both portrait), we use "cover" to fill the tile, even if it means cropping.
|
||||
* - If the video and tile have different orientations, we use "contain" to ensure the entire video is visible, even if it means letterboxing (black bars).
|
||||
* @param scope - the ObservableScope to create the Behavior in
|
||||
* @param videoSize$ - an Observable of the video size (width and height) or undefined if the size is not yet known (no data yet received).
|
||||
* @param tileSize$ - an Observable of the tile size (width and height) or undefined if the size is not yet known (not yet rendered).
|
||||
*/
|
||||
export function videoFit$(
|
||||
scope: ObservableScope,
|
||||
videoSize$: Observable<Size | undefined>,
|
||||
tileSize$: Observable<Size | undefined>,
|
||||
): Behavior<"cover" | "contain"> {
|
||||
const fit$ = combineLatest([videoSize$, tileSize$]).pipe(
|
||||
map(([videoSize, tileSize]) => {
|
||||
if (!videoSize || !tileSize) {
|
||||
// If we don't have the sizes, default to cover to avoid black bars.
|
||||
// This is a reasonable default as it will ensure the video fills the tile, even if it means cropping.
|
||||
return "cover";
|
||||
}
|
||||
if (
|
||||
videoSize.width === 0 ||
|
||||
videoSize.height === 0 ||
|
||||
tileSize.width === 0 ||
|
||||
tileSize.height === 0
|
||||
) {
|
||||
// If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars.
|
||||
return "cover";
|
||||
}
|
||||
const videoAspectRatio = videoSize.width / videoSize.height;
|
||||
const tileAspectRatio = tileSize.width / tileSize.height;
|
||||
|
||||
// If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa,
|
||||
// we want to use "contain" (fit) mode to avoid excessive cropping
|
||||
const videoIsLandscape = videoAspectRatio > 1;
|
||||
const tileIsLandscape = tileAspectRatio > 1;
|
||||
|
||||
// If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.)
|
||||
// If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.)
|
||||
return videoIsLandscape === tileIsLandscape ? "cover" : "contain";
|
||||
}),
|
||||
);
|
||||
|
||||
return scope.behavior(fit$, "cover");
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the video size from a participant.
|
||||
* It observes the participant's video track stats and extracts the frame width and height.
|
||||
* @param participant$ - an Observable of a LocalParticipant or RemoteParticipant, or null if no participant is selected.
|
||||
* @returns an Observable of the video size (width and height) or undefined if the size cannot be determined.
|
||||
*/
|
||||
export function videoSizeFromParticipant$(
|
||||
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
|
||||
): Observable<{ width: number; height: number } | undefined> {
|
||||
return participant$
|
||||
.pipe(
|
||||
// If we have a participant, observe their video track stats. If not, return undefined.
|
||||
switchMap((p) => {
|
||||
if (!p) return of(undefined);
|
||||
if (p.isLocal) {
|
||||
return observeOutboundRtpStreamStats$(p, Track.Source.Camera);
|
||||
} else {
|
||||
return observeInboundRtpStreamStats$(p, Track.Source.Camera);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
// Extract the frame width and height from the stats. If we don't have valid stats, return undefined.
|
||||
map((stats) => {
|
||||
if (!stats) return undefined;
|
||||
if (
|
||||
// For video tracks, frameWidth and frameHeight should be numbers. If they're not, we can't determine the size.
|
||||
typeof stats.frameWidth !== "number" ||
|
||||
typeof stats.frameHeight !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
width: stats.frameWidth,
|
||||
height: stats.frameHeight,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, vi, it } from "vitest";
|
||||
import { describe, expect, vi, it, beforeEach } from "vitest";
|
||||
import { createRoomWidgetClient, EventType } from "matrix-js-sdk";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
@@ -35,11 +35,14 @@ vi.mock("./UrlParams", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
initializeWidget();
|
||||
describe("widget", () => {
|
||||
beforeAll(() => {});
|
||||
beforeEach(() => {
|
||||
createRoomWidgetClientSpy.mockClear();
|
||||
});
|
||||
|
||||
describe("widget", () => {
|
||||
it("should create an embedded client with the correct params", () => {
|
||||
initializeWidget("ANYRTCAPP");
|
||||
|
||||
expect(getUrlParams()).toStrictEqual({
|
||||
widgetId: "id",
|
||||
parentUrl: "http://parentUrl",
|
||||
@@ -66,13 +69,16 @@ describe("widget", () => {
|
||||
];
|
||||
|
||||
const sendState = [
|
||||
"myYser", // Legacy call membership events
|
||||
`_myYser_AAAAA_m.call`, // Session membership events
|
||||
`myYser_AAAAA_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
].map((stateKey) => ({
|
||||
eventType: EventType.GroupCallMemberPrefix,
|
||||
stateKey,
|
||||
}));
|
||||
{ eventType: "org.matrix.msc3401.call.member", stateKey: "myYser" }, // Legacy call membership events
|
||||
{
|
||||
eventType: "org.matrix.msc3401.call.member",
|
||||
stateKey: `_myYser_AAAAA_ANYRTCAPP`,
|
||||
}, // Session membership events
|
||||
{
|
||||
eventType: "org.matrix.msc3401.call.member",
|
||||
stateKey: `myYser_AAAAA_ANYRTCAPP`,
|
||||
}, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
];
|
||||
const receiveState = [
|
||||
{ eventType: EventType.RoomCreate },
|
||||
{ eventType: EventType.RoomName },
|
||||
@@ -124,4 +130,32 @@ describe("widget", () => {
|
||||
});
|
||||
expect(createRoomWidgetClientSpy.mock.calls[0][4]).toStrictEqual(false);
|
||||
});
|
||||
|
||||
it("should request send message permission if requested", () => {
|
||||
initializeWidget("ANYRTCAPP", true);
|
||||
expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
// capabilities
|
||||
expect.objectContaining({
|
||||
sendEvent: expect.arrayContaining(["m.room.message"]),
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not request send message permission when not requested", () => {
|
||||
initializeWidget("", false);
|
||||
expect(createRoomWidgetClientSpy).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
// capabilities
|
||||
expect.objectContaining({
|
||||
sendEvent: expect.not.arrayContaining(["m.room.message"]),
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +68,10 @@ export let widget: WidgetHelpers | null;
|
||||
*/
|
||||
// this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before
|
||||
// execution.
|
||||
export const initializeWidget = (): void => {
|
||||
export const initializeWidget = (
|
||||
rtcApplication: string = "m.call",
|
||||
sendRoomEvents = false,
|
||||
): void => {
|
||||
try {
|
||||
const {
|
||||
widgetId,
|
||||
@@ -116,6 +119,9 @@ export const initializeWidget = (): void => {
|
||||
EventType.CallNotify, // Sent as a deprecated fallback
|
||||
EventType.RTCNotification,
|
||||
];
|
||||
if (sendRoomEvents) {
|
||||
sendEvent.push(EventType.RoomMessage);
|
||||
}
|
||||
const sendRecvEvent = [
|
||||
"org.matrix.rageshake_request",
|
||||
EventType.CallEncryptionKeysPrefix,
|
||||
@@ -128,8 +134,8 @@ export const initializeWidget = (): void => {
|
||||
|
||||
const sendState = [
|
||||
userId, // Legacy call membership events
|
||||
`_${userId}_${deviceId}_m.call`, // Session membership events
|
||||
`${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
`_${userId}_${deviceId}_${rtcApplication}`, // Session membership events
|
||||
`${userId}_${deviceId}_${rtcApplication}`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
].map((stateKey) => ({
|
||||
eventType: EventType.GroupCallMemberPrefix,
|
||||
stateKey,
|
||||
|
||||
Reference in New Issue
Block a user