mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Compare commits
101 Commits
valere/rem
...
robin/butt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d60b2c29 | ||
|
|
4d69565312 | ||
|
|
fa844446b6 | ||
|
|
6d14f1d06f | ||
|
|
5cf5227eaa | ||
|
|
78240c2ec8 | ||
|
|
bf8bf80417 | ||
|
|
7da9bca08c | ||
|
|
748c8e6d0d | ||
|
|
40ef16a55f | ||
|
|
07ad8374a9 | ||
|
|
a0d5c79999 | ||
|
|
3e171d9639 | ||
|
|
413329cd26 | ||
|
|
6b8f6e9405 | ||
|
|
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 | ||
|
|
bc238778ad | ||
|
|
9d37125677 | ||
|
|
d87c3293c9 | ||
|
|
c376d9e5c1 | ||
|
|
f09e2c8b55 | ||
|
|
dbdcde2489 | ||
|
|
636f737cd3 | ||
|
|
93c9225e8a | ||
|
|
582d1d8495 | ||
|
|
eb60473510 | ||
|
|
c35b960627 | ||
|
|
df2f503a05 | ||
|
|
c20b206ab2 | ||
|
|
5ceb140d13 | ||
|
|
ed4517703f | ||
|
|
bd3e91738e |
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)
|
||||
9
.github/workflows/blocked.yaml
vendored
9
.github/workflows/blocked.yaml
vendored
@@ -1,7 +1,16 @@
|
||||
name: Prevent blocked
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This workflow does not checkout code or use secrets.
|
||||
# It only reads labels to set a failure status on the PR.
|
||||
pull_request_target:
|
||||
types: [opened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
# Required to fail the check on the PR
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
|
||||
47
.github/workflows/build-and-publish-docker.yaml
vendored
47
.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
|
||||
@@ -40,12 +43,50 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Connect to Tailscale
|
||||
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # 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@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
|
||||
|
||||
|
||||
6
.github/workflows/build-element-call.yaml
vendored
6
.github/workflows/build-element-call.yaml
vendored
@@ -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
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
- name: Build Element Call
|
||||
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
|
||||
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
@@ -52,6 +54,8 @@ jobs:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
PACKAGE: ${{ inputs.package }}
|
||||
BUILD_MODE: ${{ inputs.build_mode }}
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/build.yaml
vendored
2
.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: |
|
||||
|
||||
8
.github/workflows/changelog-label.yml
vendored
8
.github/workflows/changelog-label.yml
vendored
@@ -1,8 +1,16 @@
|
||||
name: PR changelog label
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# This is safe because we do not use actions/checkout or execute untrusted code.
|
||||
# Using pull_request_target is necessary to allow status writes for PRs from forks.
|
||||
pull_request_target:
|
||||
types: [labeled, unlabeled, opened]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
pr-changelog-label:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
10
.github/workflows/deploy-to-netlify.yaml
vendored
10
.github/workflows/deploy-to-netlify.yaml
vendored
@@ -63,11 +63,15 @@ jobs:
|
||||
|
||||
- name: Add config file
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
13
.github/workflows/pr-deploy.yaml
vendored
13
.github/workflows/pr-deploy.yaml
vendored
@@ -1,5 +1,7 @@
|
||||
name: Deploy previews for PRs
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers]
|
||||
# Reason: This is now restricted to internal PRs only using the 'if' condition below.
|
||||
workflow_run:
|
||||
workflows: ["Build"]
|
||||
types:
|
||||
@@ -7,7 +9,14 @@ on:
|
||||
|
||||
jobs:
|
||||
prdetails:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
|
||||
# Logic:
|
||||
# 1. Build must be successful
|
||||
# 2. Event must be a pull_request
|
||||
# 3. Head repository must be the SAME as the base repository (No Forks!)
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.prdetails.outputs.pr_id }}
|
||||
@@ -60,7 +69,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: |
|
||||
|
||||
83
.github/workflows/publish-embedded-packages.yaml
vendored
83
.github/workflows/publish-embedded-packages.yaml
vendored
@@ -22,8 +22,18 @@ jobs:
|
||||
TAG: ${{ steps.tag.outputs.TAG }}
|
||||
steps:
|
||||
- name: Calculate VERSION
|
||||
# We should only use the hard coded test value for a dry run
|
||||
run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV"
|
||||
# Safely store dynamic values in environment variables
|
||||
# to prevent shell injection (template-injection)
|
||||
run: |
|
||||
# The logic is executed within the shell using the env variables
|
||||
if [ "$EVENT_NAME" = "release" ]; then
|
||||
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV"
|
||||
fi
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
- id: dry_run
|
||||
name: Set DRY_RUN
|
||||
# We perform a dry run for all events except releases.
|
||||
@@ -71,7 +81,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 +92,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 +116,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 +137,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 +160,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
|
||||
@@ -161,16 +180,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 +206,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 +222,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 +238,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 +261,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 +288,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: |
|
||||
|
||||
4
.github/workflows/test.yaml
vendored
4
.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
|
||||
@@ -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
|
||||
|
||||
2
.github/workflows/translations-download.yaml
vendored
2
.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
|
||||
|
||||
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@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
|
||||
21
package.json
21
package.json
@@ -43,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",
|
||||
@@ -79,7 +79,7 @@
|
||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
@@ -101,7 +101,7 @@
|
||||
"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",
|
||||
@@ -118,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",
|
||||
@@ -128,17 +128,22 @@
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-node-stdlib-browser": "^0.2.1",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
"vitest": "^3.0.0",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"@livekit/components-core/rxjs": "^7.8.1",
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18"
|
||||
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
|
||||
"minimatch": "^10.2.3",
|
||||
"tar": "^7.5.11",
|
||||
"glob": "^10.5.0",
|
||||
"qs": "^6.14.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"packageManager": "yarn@4.7.0"
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ test("Start a new call then leave and show the feedback screen", async ({
|
||||
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
|
||||
|
||||
// Check the button toolbar
|
||||
// await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible();
|
||||
// await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible();
|
||||
// await expect(page.getByRole('switch', { name: 'Mute microphone' })).toBeVisible();
|
||||
// await expect(page.getByRole('switch', { name: 'Stop video' })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "End call" })).toBeVisible();
|
||||
|
||||
|
||||
@@ -49,12 +49,12 @@ test("can only interact with header and footer while reconnecting", async ({
|
||||
).toBeVisible();
|
||||
|
||||
// Tab order should jump directly from header to footer, skipping media tiles
|
||||
await page.getByRole("button", { name: "Mute microphone" }).focus();
|
||||
await page.getByRole("switch", { name: "Mute microphone" }).focus();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Mute microphone" }),
|
||||
page.getByRole("switch", { name: "Mute microphone" }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
|
||||
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
|
||||
// Most critically, we should be able to press the hangup button
|
||||
await page.getByRole("button", { name: "End call" }).click();
|
||||
});
|
||||
|
||||
@@ -55,13 +55,10 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
|
||||
// No lobby, should start with video on
|
||||
// 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
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// We should see 5 video tiles everywhere now
|
||||
@@ -101,13 +98,15 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
|
||||
const florianFrame = florian.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
const florianMuteButton = florianFrame.getByTestId("incall_videomute");
|
||||
await florianMuteButton.click();
|
||||
const florianVideoButton = florianFrame.getByRole("switch", {
|
||||
name: /video/,
|
||||
});
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Stop video");
|
||||
await expect(florianVideoButton).toBeChecked();
|
||||
await florianVideoButton.click();
|
||||
// Now the button should indicate we can start video
|
||||
await expect(florianMuteButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Start video$/,
|
||||
);
|
||||
await expect(florianVideoButton).toHaveAccessibleName("Start video");
|
||||
await expect(florianVideoButton).not.toBeChecked();
|
||||
|
||||
// wait a bit for the state to propagate
|
||||
await valere.page.waitForTimeout(3000);
|
||||
|
||||
73
playwright/widget/pip-call-button-interaction.test.ts
Normal file
73
playwright/widget/pip-call-button-interaction.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
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 audioBtn = iFrame.getByRole("switch", { name: /microphone/ });
|
||||
const videoBtn = iFrame.getByRole("switch", { name: /video/ });
|
||||
await expect(
|
||||
iFrame.getByRole("button", { name: "End call" }),
|
||||
).toBeVisible();
|
||||
await expect(audioBtn).toBeVisible();
|
||||
await expect(videoBtn).toBeVisible();
|
||||
await expect(audioBtn).toHaveAccessibleName("Mute microphone");
|
||||
await expect(audioBtn).toBeChecked();
|
||||
await expect(videoBtn).toHaveAccessibleName("Stop video");
|
||||
await expect(videoBtn).toBeChecked();
|
||||
|
||||
await videoBtn.click();
|
||||
await audioBtn.click();
|
||||
|
||||
// stop hovering on any of the buttons
|
||||
await iFrame.getByTestId("videoTile").hover();
|
||||
|
||||
await expect(audioBtn).toHaveAccessibleName("Unmute microphone");
|
||||
await expect(audioBtn).toBeChecked();
|
||||
await expect(videoBtn).toHaveAccessibleName("Start video");
|
||||
await expect(videoBtn).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
72
playwright/widget/pip-call.test.ts
Normal file
72
playwright/widget/pip-call.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
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();
|
||||
|
||||
// check that the video is on
|
||||
await expect(
|
||||
frame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,34 +51,36 @@ widgetTest(
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Start video",
|
||||
checked: false,
|
||||
}),
|
||||
).toBeVisible();
|
||||
// audio should be on for the voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be off by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Start video",
|
||||
checked: false,
|
||||
}),
|
||||
).toBeVisible();
|
||||
// audio should be on for the voice call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
@@ -90,10 +92,7 @@ widgetTest(
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
@@ -142,34 +141,30 @@ widgetTest(
|
||||
.contentFrame();
|
||||
|
||||
// ASSERT the button states for whistler (the callee)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = whistlerFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = whistlerFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a video call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
// audio should be on too
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// ASSERT the button states for brools (the caller)
|
||||
{
|
||||
// The only way to know if it is muted or not is to look at the data-kind attribute..
|
||||
const videoButton = brooksFrame.getByTestId("incall_videomute");
|
||||
// video should be on by default in a voice call
|
||||
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
|
||||
|
||||
const audioButton = brooksFrame.getByTestId("incall_mute");
|
||||
// audio should be on for the voice call
|
||||
await expect(audioButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
/^Mute microphone$/,
|
||||
);
|
||||
}
|
||||
// video should be off by default in a video call
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
|
||||
).toBeVisible();
|
||||
// audio should be on too
|
||||
await expect(
|
||||
whistlerFrame.getByRole("switch", {
|
||||
name: "Mute microphone",
|
||||
checked: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
|
||||
// So first we need to confirm that it is hidden when in the call.
|
||||
@@ -181,10 +176,7 @@ widgetTest(
|
||||
).not.toBeVisible();
|
||||
|
||||
// ASSERT hanging up on one side ends the call for both
|
||||
{
|
||||
const hangupButton = brooksFrame.getByTestId("incall_leave");
|
||||
await hangupButton.click();
|
||||
}
|
||||
await brooksFrame.getByRole("button", { name: "End call" }).click();
|
||||
|
||||
// The widget should be closed on both sides and the timeline should be back on screen
|
||||
await expect(
|
||||
|
||||
@@ -22,23 +22,25 @@ import {
|
||||
import styles from "./Button.module.css";
|
||||
|
||||
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon;
|
||||
const label = muted
|
||||
? t("unmute_microphone_button_label")
|
||||
: t("mute_microphone_button_label");
|
||||
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("mute_microphone_button_label")
|
||||
: t("unmute_microphone_button_label");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -46,23 +48,25 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
|
||||
};
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
muted: boolean;
|
||||
enabled: boolean;
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon;
|
||||
const label = muted
|
||||
? t("start_video_button_label")
|
||||
: t("stop_video_button_label");
|
||||
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
|
||||
const label = enabled
|
||||
? t("stop_video_button_label")
|
||||
: t("start_video_button_label");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
aria-label={label}
|
||||
Icon={Icon}
|
||||
kind={muted ? "primary" : "secondary"}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -71,6 +75,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
size: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
@@ -88,13 +93,19 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
|
||||
iconOnly
|
||||
Icon={ShareScreenSolidIcon}
|
||||
kind={enabled ? "primary" : "secondary"}
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
size?: "sm" | "lg";
|
||||
}
|
||||
|
||||
export const EndCallButton: FC<EndCallButtonProps> = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
@@ -105,7 +116,6 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
<CpdButton
|
||||
className={classNames(className, styles.endCall)}
|
||||
iconOnly
|
||||
aria-label={t("hangup_button_label")}
|
||||
Icon={EndCallIcon}
|
||||
destructive
|
||||
{...props}
|
||||
@@ -114,9 +124,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>>;
|
||||
|
||||
@@ -30,7 +30,13 @@ import {
|
||||
} from "../utils/test";
|
||||
import { initializeWidget } from "../widget";
|
||||
initializeWidget();
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
export const TestAudioContextConstructor = vi.fn(
|
||||
class {
|
||||
public constructor() {
|
||||
return testAudioContext;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
|
||||
|
||||
|
||||
@@ -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}
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
disabled={toggleAudio === null}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
size={buttonSize}
|
||||
key="video"
|
||||
muted={!videoEnabled}
|
||||
enabled={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} />
|
||||
|
||||
@@ -230,12 +230,12 @@ export const LobbyView: FC<Props> = ({
|
||||
{recentsButtonInFooter && recentsButton}
|
||||
<div className={inCallStyles.buttons}>
|
||||
<MicButton
|
||||
muted={!audioEnabled}
|
||||
enabled={audioEnabled}
|
||||
onClick={toggleAudio ?? undefined}
|
||||
disabled={toggleAudio === null}
|
||||
/>
|
||||
<VideoButton
|
||||
muted={!videoEnabled}
|
||||
enabled={videoEnabled}
|
||||
onClick={toggleVideo ?? undefined}
|
||||
disabled={toggleVideo === null}
|
||||
/>
|
||||
|
||||
@@ -285,14 +285,14 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-label="Unmute microphone"
|
||||
aria-labelledby="_r_8_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="primary"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="incall_mute"
|
||||
role="button"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
@@ -309,14 +309,14 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-disabled="true"
|
||||
aria-label="Start video"
|
||||
aria-labelledby="_r_d_"
|
||||
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
|
||||
data-kind="primary"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
data-testid="incall_videomute"
|
||||
role="button"
|
||||
role="switch"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
@@ -354,7 +354,6 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-label="End call"
|
||||
aria-labelledby="_r_n_"
|
||||
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
|
||||
data-kind="primary"
|
||||
|
||||
@@ -5,13 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { type Room as LivekitRoom } from "livekit-client";
|
||||
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
|
||||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
import { customLivekitUrl as customLivekitUrlSetting } from "./settings";
|
||||
// Mock url params hook to avoid environment-dependent snapshot churn.
|
||||
vi.mock("../UrlParams", () => ({
|
||||
useUrlParams: (): { mocked: boolean; answer: number } => ({
|
||||
@@ -20,6 +23,14 @@ vi.mock("../UrlParams", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// IMPORTANT: mock the same specifier used by DeveloperSettingsTab
|
||||
vi.mock("../livekit/openIDSFU", () => ({
|
||||
getSFUConfigWithOpenID: vi.fn().mockResolvedValue({
|
||||
url: "mock-url",
|
||||
jwt: "mock-jwt",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Provide a minimal mock of a Livekit Room structure used by the component.
|
||||
function createMockLivekitRoom(
|
||||
wsUrl: string,
|
||||
@@ -86,6 +97,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
const { container } = render(
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId={"#room:example.org"}
|
||||
livekitRooms={livekitRooms}
|
||||
env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv}
|
||||
/>,
|
||||
@@ -99,4 +111,141 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
describe("custom livekit url", () => {
|
||||
afterEach(() => {
|
||||
customLivekitUrlSetting.setValue(null);
|
||||
});
|
||||
const client = {
|
||||
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
|
||||
getCrypto: () => ({ getVersion: (): string => "x" }),
|
||||
getUserId: () => "@u:hs",
|
||||
getDeviceId: () => "DEVICE",
|
||||
} as unknown as MatrixClient;
|
||||
it("will not update custom livekit url without roomId", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.invalid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will not update custom livekit url without text in input", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will not update custom livekit url when pressing cancel", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.invalid");
|
||||
|
||||
const cancelButton = screen.getByRole("button", {
|
||||
name: "Reset overwrite",
|
||||
});
|
||||
await user.click(cancelButton);
|
||||
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
it("will update custom livekit url", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.valid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
await user.click(saveButton);
|
||||
expect(getSFUConfigWithOpenID).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
"wss://example.livekit.valid",
|
||||
"#testRoom",
|
||||
);
|
||||
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(
|
||||
"wss://example.livekit.valid",
|
||||
);
|
||||
});
|
||||
it("will show error on invalid url", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Custom Livekit-url");
|
||||
await user.clear(input);
|
||||
await user.type(input, "wss://example.livekit.valid");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
(getSFUConfigWithOpenID as Mock).mockImplementation(() => {
|
||||
throw new Error("Invalid URL");
|
||||
});
|
||||
await user.click(saveButton);
|
||||
expect(
|
||||
screen.getByText("invalid URL (did not update)"),
|
||||
).toBeInTheDocument();
|
||||
expect(customLivekitUrlSetting.getValue()).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
EditInPlace,
|
||||
ErrorMessage,
|
||||
Root as Form,
|
||||
Heading,
|
||||
HelpMessage,
|
||||
@@ -45,9 +46,11 @@ import {
|
||||
} from "./settings";
|
||||
import styles from "./DeveloperSettingsTab.module.css";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
roomId?: string;
|
||||
livekitRooms?: {
|
||||
room: LivekitRoom;
|
||||
url: string;
|
||||
@@ -60,6 +63,7 @@ interface Props {
|
||||
export const DeveloperSettingsTab: FC<Props> = ({
|
||||
client,
|
||||
livekitRooms,
|
||||
roomId,
|
||||
env,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -97,6 +101,8 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
alwaysShowIphoneEarpieceSetting,
|
||||
);
|
||||
|
||||
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
|
||||
useState<string | null>(null);
|
||||
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
|
||||
customLivekitUrlSetting,
|
||||
);
|
||||
@@ -234,14 +240,36 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
savingLabel={t("developer_mode.custom_livekit_url.saving")}
|
||||
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")}
|
||||
onSave={useCallback(
|
||||
(e: React.FormEvent<HTMLFormElement>) => {
|
||||
setCustomLivekitUrl(
|
||||
customLivekitUrlTextBuffer === ""
|
||||
? null
|
||||
: customLivekitUrlTextBuffer,
|
||||
);
|
||||
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
if (
|
||||
roomId === undefined ||
|
||||
customLivekitUrlTextBuffer === "" ||
|
||||
customLivekitUrlTextBuffer === null
|
||||
) {
|
||||
setCustomLivekitUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = client.getUserId();
|
||||
const deviceId = client.getDeviceId();
|
||||
|
||||
if (userId === null || deviceId === null) {
|
||||
throw new Error("Invalid user or device ID");
|
||||
}
|
||||
await getSFUConfigWithOpenID(
|
||||
client,
|
||||
{ userId, deviceId, memberId: "" },
|
||||
customLivekitUrlTextBuffer,
|
||||
roomId,
|
||||
);
|
||||
setCustomLivekitUrlUpdateError(null);
|
||||
setCustomLivekitUrl(customLivekitUrlTextBuffer);
|
||||
} catch {
|
||||
setCustomLivekitUrlUpdateError("invalid URL (did not update)");
|
||||
}
|
||||
},
|
||||
[setCustomLivekitUrl, customLivekitUrlTextBuffer],
|
||||
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId],
|
||||
)}
|
||||
value={customLivekitUrlTextBuffer ?? ""}
|
||||
onChange={useCallback(
|
||||
@@ -256,7 +284,12 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
},
|
||||
[setCustomLivekitUrl],
|
||||
)}
|
||||
/>
|
||||
serverInvalid={customLivekitUrlUpdateError !== null}
|
||||
>
|
||||
{customLivekitUrlUpdateError !== null && (
|
||||
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
|
||||
)}
|
||||
</EditInPlace>
|
||||
<Heading as="h3" type="body" weight="semibold" size="lg">
|
||||
{t("developer_mode.matrixRTCMode.title")}
|
||||
</Heading>
|
||||
|
||||
@@ -213,6 +213,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
env={import.meta.env}
|
||||
client={client}
|
||||
livekitRooms={livekitRooms}
|
||||
roomId={roomId}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -34,15 +34,20 @@ export class Setting<T> {
|
||||
|
||||
this._value$ = new BehaviorSubject(initialValue);
|
||||
this.value$ = this._value$;
|
||||
this._lastUpdateReason$ = new BehaviorSubject<string | null>(null);
|
||||
this.lastUpdateReason$ = this._lastUpdateReason$;
|
||||
}
|
||||
|
||||
private readonly key: string;
|
||||
|
||||
private readonly _value$: BehaviorSubject<T>;
|
||||
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
|
||||
public readonly value$: Behavior<T>;
|
||||
public readonly lastUpdateReason$: Behavior<string | null>;
|
||||
|
||||
public readonly setValue = (value: T): void => {
|
||||
public readonly setValue = (value: T, reason?: string): void => {
|
||||
this._value$.next(value);
|
||||
this._lastUpdateReason$.next(reason ?? null);
|
||||
localStorage.setItem(this.key, JSON.stringify(value));
|
||||
};
|
||||
public readonly getValue = (): T => {
|
||||
|
||||
@@ -51,14 +51,7 @@ 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,
|
||||
@@ -70,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";
|
||||
@@ -92,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,
|
||||
@@ -144,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
|
||||
@@ -193,7 +192,6 @@ interface LayoutScanState {
|
||||
tiles: TileStore;
|
||||
}
|
||||
|
||||
type MediaItem = UserMedia | ScreenShare;
|
||||
export type LivekitRoomItem = {
|
||||
livekitRoom: LivekitRoom;
|
||||
participants: string[];
|
||||
@@ -283,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>;
|
||||
@@ -334,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.
|
||||
*/
|
||||
@@ -721,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$,
|
||||
@@ -767,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)),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -821,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")),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -888,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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -934,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),
|
||||
@@ -944,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] : [])),
|
||||
@@ -956,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$,
|
||||
@@ -968,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)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
@@ -998,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),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1039,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(
|
||||
@@ -1053,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,
|
||||
@@ -1085,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
|
||||
@@ -1138,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())
|
||||
@@ -1245,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
|
||||
@@ -1296,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);
|
||||
@@ -1552,11 +1527,7 @@ export function createCallViewModel$(
|
||||
toggleSpotlightExpanded$: toggleSpotlightExpanded$,
|
||||
gridMode$: gridMode$,
|
||||
setGridMode: setGridMode,
|
||||
grid$: grid$,
|
||||
spotlight$: spotlight$,
|
||||
pip$: pip$,
|
||||
layout$: layout$,
|
||||
userMedia$,
|
||||
localMatrixLivekitMember$,
|
||||
matrixLivekitMembers$: scope.behavior(
|
||||
matrixLivekitMembers$.pipe(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,210 +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(
|
||||
`${this.id} screenShares$`,
|
||||
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)}
|
||||
|
||||
@@ -90,11 +90,18 @@ export const testAudioContext = {
|
||||
createStereoPanner: vi.fn().mockReturnValue(panNode),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
|
||||
const TestAudioContext = vi.fn(
|
||||
class {
|
||||
public constructor() {
|
||||
return testAudioContext;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let user: UserEvent;
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
|
||||
vi.stubGlobal("AudioContext", TestAudioContext);
|
||||
user = userEvent.setup();
|
||||
});
|
||||
|
||||
|
||||
@@ -61,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");
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
@@ -416,10 +443,10 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
public asMockedSession(): MockedObject<MatrixRTCSession> {
|
||||
const session = this as unknown as MockedObject<MatrixRTCSession>;
|
||||
|
||||
vi.mocked(session).reemitEncryptionKeys = vi
|
||||
session.reemitEncryptionKeys = vi
|
||||
.fn<() => void>()
|
||||
.mockReturnValue(undefined);
|
||||
vi.mocked(session).getOldestMembership = vi
|
||||
session.getOldestMembership = vi
|
||||
.fn<() => CallMembership | undefined>()
|
||||
.mockReturnValue(this.memberships[0]);
|
||||
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user