Compare commits

..

23 Commits

Author SHA1 Message Date
davd-gzl 02a355136f 🔒️(ci) escape the downloaded gitmojis before the regex
The rule joins the list it fetches into an alternation, so any regex character
in a gitmoji entry would change what the pattern matches. re.escape treats each
one as a literal, which is what the match was always meant to do.
2026-08-31 19:03:27 +02:00
davd-gzl fbeb035f50 🔒️(ci) install every Python job through uv
lint-git runs gitlint through uvx, which fetches it for that one command and
puts nothing in the runner's Python. lint-summary and test-summary sync from a
new src/summary/uv.lock, so the versions those jobs resolve are the versions in
the tree, which is what src/backend and src/agents already do. The agents sync
refuses source distributions, since that package installs no project of its own.

Follows suitenumerique/menshen#73.
2026-08-31 19:03:27 +02:00
davd-gzl a0dbfa9357 🔥(ci) drop the requests dependency from the gitmoji rule
The rule downloaded the gitmoji list with requests, so lint-git had to install
that package before the linter could run. urllib.request is in the standard
library and answers the same call, leaving one fewer package fetched on the
runner before the job's own command starts.
2026-08-31 19:03:27 +02:00
davd-gzl 0e9660ead3 🔥(ci) leave the action pins to Renovate 2026-08-31 19:03:27 +02:00
davd-gzl 1721eb0884 🔒️(ci) keep uv run from building a source distribution
--no-sync already stops uv run resolving an environment of its own,
and that is what the version findings were about. Building is a
separate guarantee that nothing on the line carried, so --no-build
now says it outright. It is inert beside --no-sync, and the three
lint jobs are unchanged.
2026-08-31 19:03:27 +02:00
davd-gzl 7538cd886b 🚚(ci) rename meet.yml to ci.yml
The repository is already called meet, so the file name said nothing
about what the workflow holds. The print-statement check now excludes
the whole workflows directory rather than one file by name: its own
grep carries the literal print(, so the rename would otherwise match
it on the deleted lines and fail the job.
2026-08-31 19:03:27 +02:00
davd-gzl aafbc752d5 🔒️(ci) pin every action to a full commit hash
A tag and a branch both move, so actions/checkout@v6 and
numerique-gouv/action-trivy-cache@main ran whatever the owner had last
pushed. Each of the 65 uses now names a 40-character commit, with the
version it resolved to in a trailing comment.

dependabot.yml keeps the hash and that comment moving together.
2026-08-31 19:03:27 +02:00
davd-gzl a24100aceb 🔒️(ci) harden the remaining fetches in the workflow
gitlint-core keeps the linter at 0.19.1, which the wheels-only pin was
resolving down to 0.18.0 through an sdist-only sh. --ignore-scripts
stops three npm ci, one yarn install and one npm install -g running
the scripts of what they fetch, and yarn is pinned to 1.22.22. curl
holds the dockerize download and its redirects to https.
2026-08-31 19:03:27 +02:00
davd-gzl 9e2383a341 🔒️(ci) install dependencies without running their setup code
A package could run arbitrary code on the runner while installing,
and the versions were resolved rather than taken from the pins.
The two pip calls now take wheels only, and uv run no longer
resolves an environment of its own.
2026-08-31 19:03:27 +02:00
moustique82 80d37595ad (frontend) expose publish permissions in MediaStateObserver
The component exposes the local microphone and camera state so external
tools automating the frontend can read it. It does not expose whether
publication is allowed, so such a tool cannot tell a muted microphone from
one it is not permitted to unmute, and ends up offering a control that
silently does nothing.

Expose canPublishMicrophone and canPublishCamera in the event detail and as
data attributes, reusing the useCanPublishTrack hook that already gates the
web client controls. The permissions are part of the effect dependencies so
a mid-meeting permission change emits the event.
2026-08-31 16:55:36 +02:00
snyk-bot 2daa668075 ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
Snyk has created this PR to upgrade core-js from 3.49.0 to 3.50.0.

See this package in npm:
core-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/af693e79-8c43-4c09-ab65-60580515c9e8?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-31 16:43:41 +02:00
Cyril a1e7978348 ️(frontend) announce Escape close hint on side panels
Show "(Escape)" in close tooltip, add aria-describedby for SR keyboard hint.
2026-08-31 15:57:59 +02:00
Cyril 4fa044fa3e ️(frontend) close side panel with Escape key
useEscapeToClose: close panel on Escape, restore focus, let chat input bubble
2026-08-31 15:57:59 +02:00
leo 2c5dd151f1 ♻️(backend) factorize s3 client creation in utils
Factorize s3 client creation in utils for code simplification.
2026-08-31 15:44:38 +02:00
leo a33e35111c ♻️(devex) update Makefile lint targets and harmonize service naming
The make lint target did not cover the summary and agents components. Update
the linting workflow to include both services and harmonize Makefile target
names. Harmonize Docker compose user declarations.
2026-08-31 14:52:10 +02:00
snyk-bot 02714c869a ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
Snyk has created this PR to upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0.

See this package in npm:
@fontsource/opendyslexic

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-31 14:37:44 +02:00
snyk-bot 826cfe0c62 ⬆️(frontend) upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0
Snyk has created this PR to upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0.

See this package in npm:
@fontsource-variable/lexend

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-31 14:10:02 +02:00
Louis-Jean Teitelbaum ab62ae71ea 🐛(frontend) fix chat text-area bug
Whenever a character was typed, the caret would jump
to the end of the textarea. Fixes #1656.
2026-08-31 13:59:39 +02:00
Miguel Victoria 3991235903 More natural language on interface 2026-08-31 13:59:21 +02:00
Miguel Victoria d2a74a20fd chore: improve backend translations 2026-08-31 13:59:21 +02:00
Miguel Victoria 0446d1e824 update comment lines on django.po files 2026-08-31 13:59:21 +02:00
Villaquiranm a20a0a6b38 🌐(i18n) add Spanish language support
Add Spanish to the language selection system on both trees, with 872
frontend phrases and 145 backend ones translated from the French source.

The frontend to backend language map gains it too, so a Spanish browser
does not write an empty language to the account on every load.
2026-08-31 13:59:21 +02:00
snyk-bot 564b3dc595 ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
Snyk has created this PR to upgrade posthog-js from 1.409.5 to 1.414.0.

See this package in npm:
posthog-js

See this project in Snyk:
https://app.eu.snyk.io/org/lasuite-dinum-default/project/96ea03d8-8d09-493d-86bf-363f274e129e?utm_source=github&utm_medium=referral&page=upgrade-pr
2026-08-31 13:56:44 +02:00
51 changed files with 4354 additions and 1488 deletions
@@ -1,4 +1,4 @@
name: meet Workflow
name: CI
on:
push:
@@ -18,7 +18,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
- name: show
@@ -26,17 +26,17 @@ jobs:
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
- name: Install uv
if: always()
run: pip install --user requests gitlint
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
@@ -47,7 +47,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -59,7 +59,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -77,15 +77,15 @@ jobs:
working-directory: src/mail
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: "22"
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -93,11 +93,11 @@ jobs:
- name: Install yarn
if: steps.mail-templates.outputs.cache-hit != 'true'
run: npm install -g yarn
run: npm install -g --ignore-scripts yarn@1.22.22
- name: Install node dependencies
if: steps.mail-templates.outputs.cache-hit != 'true'
run: yarn install --frozen-lockfile
run: yarn install --frozen-lockfile --ignore-scripts
- name: Build mails
if: steps.mail-templates.outputs.cache-hit != 'true'
@@ -105,7 +105,7 @@ jobs:
- name: Cache mail templates
if: steps.mail-templates.outputs.cache-hit != 'true'
uses: actions/cache@v5
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
@@ -119,22 +119,22 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: uv run ruff format . --diff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run ruff check .
run: uv run --no-sync --no-build ruff check .
- name: Lint code with pylint
run: uv run pylint meet demo core
run: uv run --no-sync --no-build pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
@@ -145,19 +145,19 @@ jobs:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
run: uv sync --locked --all-extras --no-build
- name: Check code formatting with ruff
run: uv run ruff format . --diff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: uv run ruff check .
run: uv run --no-sync --no-build ruff check .
lint-summary:
runs-on: ubuntu-latest
@@ -168,18 +168,19 @@ jobs:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run --no-sync --no-build ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run --no-sync --no-build ruff check .
test-back:
runs-on: ubuntu-latest
@@ -235,7 +236,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Create writable /data
run: |
@@ -243,7 +244,7 @@ jobs:
sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v5
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
id: mail-templates
with:
path: "src/backend/core/templates/mail"
@@ -262,7 +263,9 @@ jobs:
# Tool to wait for a service to be ready
- name: Install Dockerize
run: |
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
sudo tar -C /usr/local/bin -xzv
- name: Wait for MinIO to be ready
run: |
@@ -277,11 +280,11 @@ jobs:
mc mb meet/meet-media-storage"
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the dependencies
run: uv sync --locked --all-extras
@@ -291,10 +294,10 @@ jobs:
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: uv run python manage.py compilemessages
run: uv run --no-sync --no-build python manage.py compilemessages
- name: Run tests
run: uv run pytest -n 2
run: uv run --no-sync --no-build pytest -n 2
test-summary:
runs-on: ubuntu-latest
@@ -320,7 +323,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install ffmpeg
run: |
@@ -328,16 +331,18 @@ jobs:
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@v6
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install the project
run: uv sync --locked --all-extras
- name: Run summary tests
run: ~/.local/bin/pytest
run: uv run --no-sync --no-build pytest
lint-front:
runs-on: ubuntu-latest
@@ -345,10 +350,10 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install dependencies
run: cd src/frontend/ && npm ci
run: cd src/frontend/ && npm ci --ignore-scripts
- name: Check linting
run: cd src/frontend/ && npm run lint
@@ -365,10 +370,10 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Check linting
run: npm run lint
@@ -386,10 +391,10 @@ jobs:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts
- name: Build SDK
run: npm run build
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- name: Download Crowdin files
uses: crowdin/github-action@v2
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
with:
upload_sources: false
upload_translations: false
+36 -36
View File
@@ -30,36 +30,36 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
target: backend-production
@@ -76,36 +76,36 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./src/frontend/Dockerfile
@@ -123,36 +123,36 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: ./docker/dinum-frontend/Dockerfile
@@ -170,30 +170,30 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
@@ -201,7 +201,7 @@ jobs:
docker-context: './src/summary'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/summary
file: ./src/summary/Dockerfile
@@ -219,30 +219,30 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
-
name: Set up QEMU
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
uses: docker/login-action@v3
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
@@ -250,7 +250,7 @@ jobs:
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./src/agents
file: ./src/agents/Dockerfile
@@ -273,7 +273,7 @@ jobs:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
id: notify
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
+3 -3
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
fetch-depth: 0
@@ -21,12 +21,12 @@ jobs:
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
with:
charts_dir: ./src/helm
linting: on
+15
View File
@@ -8,14 +8,29 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿️(frontend) close side panel with Escape key #1507
### Added
- ✨(agent) support Voxtral realtime as inference engine
- 🌐(i18n) add Spanish language support
- ✨(frontend) expose publish permissions on the media state element #1661
### Changed
- 🔥(backend) remove the S3 storage-event webhook for recordings
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
- ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
- ⬆️(frontend) upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
- ♻️(backend) factorize s3 client creation in utils
### Fixed
- 🐛(frontend) fix chat text-area bug
## [1.29.0] - 2026-08-25
+33 -13
View File
@@ -39,14 +39,16 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -59,6 +61,10 @@ LINT_PYLINT = pylint meet demo core
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
LINT_AGENTS = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -198,23 +204,37 @@ demo: ## flush db then create a demo for load testing purpose
@$(MANAGE) create_demo
.PHONY: demo
lint: ## lint back-end python sources
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
lint: ## lint all python sources (back-end, agents, summary)
@$(MAKE) lint-back
@$(MAKE) lint-agents
@$(MAKE) lint-summary
.PHONY: lint
lint-back: ## lint back-end python sources
@$(COMPOSE_RUN_LINT_BACK) sh -c "$(LINT_BACK)"
.PHONY: lint-back
lint-agents: ## lint agents python sources
@$(COMPOSE_RUN_LINT_AGENTS) sh -c "$(LINT_AGENTS)"
.PHONY: lint-agents
lint-summary: ## lint summary python sources
@$(COMPOSE_RUN_LINT_SUMMARY) sh -c "$(LINT_SUMMARY)"
.PHONY: lint-summary
lint-ruff-format: ## format back-end python sources with ruff
@echo 'lint:ruff-format started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT)
.PHONY: lint-ruff-format
lint-ruff-check: ## lint back-end python sources with ruff
@echo 'lint:ruff-check started…'
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK)
.PHONY: lint-ruff-check
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
@echo 'lint:pylint started…'
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT)
.PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
+2 -2
View File
@@ -213,8 +213,6 @@ services:
- "7880:7880"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on:
@@ -235,6 +233,7 @@ services:
build:
context: ./src/agents
target: development
user: ${DOCKER_USER:-1000}
command: ["python", "metadata_collector.py", "dev"]
env_file:
- env.d/development/metadata_collector
@@ -253,6 +252,7 @@ services:
build:
context: ./src/agents
target: development
user: ${DOCKER_USER:-1000}
command: ["python", "multi_user_transcriber.py", "dev"]
env_file:
- env.d/development/multi_user_transcriber
-13
View File
@@ -8,16 +8,3 @@ webhook:
api_key: devkey
urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
turn:
enabled: true
domain: turn.192.168.1.50.nip.io
udp_port: 3478
tls_port: 0
external_tls: false
relay_range_start: 30000
relay_range_end: 30100
allow_restricted_peer_cidrs:
- 192.168.0.0/16
- 172.16.0.0/12
+7 -8
View File
@@ -3,14 +3,14 @@ Gitlint extra rule to validate that the message title is of the form
"<gitmoji>(<scope>) <subject>"
"""
from __future__ import unicode_literals
import json
import re
import requests
import urllib.request
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
GITMOJIS_URL = "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
class GitmojiTitle(LineRule):
"""
@@ -28,10 +28,9 @@ class GitmojiTitle(LineRule):
Download the list possible gitmojis from the project's github repository and check that
title contains one of them.
"""
gitmojis = requests.get(
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
).json()["gitmojis"]
emojis = [item["emoji"] for item in gitmojis]
with urllib.request.urlopen(GITMOJIS_URL, timeout=10) as response:
gitmojis = json.load(response)["gitmojis"]
emojis = [re.escape(item["emoji"]) for item in gitmojis]
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
if not re.search(pattern, title):
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
+8 -5
View File
@@ -9,8 +9,8 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"core-js": "3.49.0",
"i18next": "^26.3.6",
"core-js": "3.50.0",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -6863,11 +6863,14 @@
}
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
+1 -1
View File
@@ -26,7 +26,7 @@
"watch": "webpack --mode development --watch"
},
"dependencies": {
"core-js": "3.49.0",
"core-js": "3.50.0",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
+27 -40
View File
@@ -381,6 +381,31 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
return mimetype_from_content or "application/octet-stream"
def _get_s3_client(*, override_domain: bool = True):
"""Return an S3 client, honoring the AWS_S3_DOMAIN_REPLACE endpoint override.
AWS_S3_DOMAIN_REPLACE is used when the backend and frontend reach object
storage under different domains (this is the case in the docker compose stack
used in development: the frontend connects to the object storage on localhost
while the backend uses the object storage service name declared in the stack).
The domain name is used to compute the signature, so it can't be changed
dynamically by the frontend; we build a dedicated boto3 client pointed at that
endpoint. Otherwise we reuse the default storage client.
"""
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
return boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
return default_storage.connection.meta.client
def generate_upload_policy(file):
"""
Generate a S3 upload policy for a given file.
@@ -391,26 +416,7 @@ def generate_upload_policy(file):
key = file.temporary_file_key
# This settings should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
s3_client = _get_s3_client()
# Generate the policy
policy = s3_client.generate_presigned_url(
@@ -431,26 +437,7 @@ def generate_download_s3_url(
if not key:
raise ValueError("key cannot be empty")
# This setting should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
s3_client = _get_s3_client(override_domain=override_domain)
return s3_client.generate_presigned_url(
ClientMethod="get_object",
@@ -677,3 +677,7 @@ msgstr "Niederländisch"
#: meet/settings.py:231
msgid "German"
msgstr "Deutsch"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spanisch"
@@ -672,3 +672,7 @@ msgstr "Dutch"
#: meet/settings.py:231
msgid "German"
msgstr "German"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spanish"
@@ -0,0 +1,652 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:67
msgid "Personal info"
msgstr "Datos Personales"
#: core/admin.py:80
msgid "Permissions"
msgstr "Permisos"
#: core/admin.py:92
msgid "Important dates"
msgstr "Fechas importantes"
#: core/admin.py:206
msgid "Content"
msgstr "Contenido"
#: core/admin.py:217
msgid "Deletion"
msgstr "Eliminar Salas"
#: core/admin.py:226
msgid "Derived info"
msgstr ""
#: core/admin.py:237
# 'Marcas de tiempo' is a bad translation for spanish
msgid "Timestamps"
msgstr ""
#: core/admin.py:240
msgid "File preview"
msgstr "Vista previa"
#: core/admin.py:300 core/admin.py:443
msgid "No owner"
msgstr "Sin propietario"
#: core/admin.py:303 core/admin.py:446
msgid "Multiple owners"
msgstr "Varios propietarios"
#: core/admin.py:316
msgid "Resend notification to external service"
msgstr "Reenviar la notificación al servicio externo"
#: core/admin.py:339
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Error al notificar la grabación %(id)s"
#: core/admin.py:347
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Error al notificar la grabación %(id)s: %(error)s"
#: core/admin.py:355
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notificaciones enviadas correctamente para %(count)s grabación(es)."
#: core/admin.py:363
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Se han omitido %(count)s grabación(es) caducada(s)."
#: core/admin.py:368
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marcar las grabaciones seleccionadas como «Error al detener»"
#: core/admin.py:386
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
#: core/admin.py:394
#, python-format
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible."
#: core/admin.py:510
msgid "No scopes"
msgstr ""
#: core/admin.py:512
msgid "Scopes"
msgstr "Ámbitos"
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Yo soy el creador"
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "Debes ser administrador o propietario de una reunión para añadirle accesos."
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Esta extensión de archivo no está permitida."
#: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type."
msgstr "Has alcanzado el número máximo de archivos de este tipo."
#: core/models.py:37
msgid "Member"
msgstr "Miembro"
#: core/models.py:38
msgid "Administrator"
msgstr "Administrador"
#: core/models.py:39
msgid "Owner"
msgstr "Propietario"
#: core/models.py:55
# To check here and following lines for gender agreement (Masculine/Feminine)
msgid "Initiated"
msgstr "Iniciada"
#: core/models.py:56
msgid "Active"
msgstr "Activa"
#: core/models.py:57
msgid "Stopped"
msgstr "Detenida"
#: core/models.py:58
msgid "Saved"
msgstr "Guardada"
#: core/models.py:59
msgid "Aborted"
msgstr "Cancelada"
#: core/models.py:60
msgid "Failed to Start"
msgstr "Error al iniciar"
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Error al detener"
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notificado correctamente"
#: core/models.py:65
msgid "External process successful"
msgstr "Proceso externo finalizado correctamente"
#: core/models.py:67
msgid "External process failed"
msgstr "Error en el Proceso externo"
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "GRABACIÓN_DE_PANTALLA"
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPCIÓN"
#: core/models.py:103
msgid "Public Access"
msgstr "Acceso público"
#: core/models.py:104
msgid "Trusted Access"
msgstr "Acceso usuarios autorizados"
#: core/models.py:105
msgid "Restricted Access"
msgstr "Acceso restringido"
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "clave primaria del registro en forma de UUID"
#: core/models.py:124
msgid "created on"
msgstr "creado el"
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "fecha y hora en que se creó un registro"
#: core/models.py:130
msgid "updated on"
msgstr "actualizado el"
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "fecha y hora de la última actualización de un registro"
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr "Introduce un sub válido. Este valor solo puede contener letras, números y los caracteres @/./+/-/_."
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "Opcional para los usuarios pendientes; obligatorio al activar la cuenta. 255 caracteres como máximo. Solo letras, números y los caracteres @/./+/-/_."
#: core/models.py:168
msgid "identity email address"
msgstr "dirección de correo electrónico"
#: core/models.py:173
msgid "admin email address"
msgstr "dirección de correo electrónico de administrador"
#: core/models.py:175
msgid "full name"
msgstr "nombre completo"
#: core/models.py:177
msgid "short name"
msgstr "nombre corto"
#: core/models.py:183
msgid "language"
msgstr "idioma"
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "El idioma preferido de interfaz."
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver las horas."
#: core/models.py:193
msgid "device"
msgstr "dispositivo"
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real."
#: core/models.py:198
msgid "staff status"
msgstr "estado del personal"
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede acceder a este sitio de administración."
#: core/models.py:203
msgid "active"
msgstr "activo"
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr "Si este usuario debe considerarse activo. Desmarca esta opción en lugar de eliminar cuentas."
#: core/models.py:219
msgid "user"
msgstr "usuario"
#: core/models.py:220
msgid "users"
msgstr "usuarios"
#: core/models.py:286
msgid "Resource"
msgstr "Recurso"
#: core/models.py:287
msgid "Resources"
msgstr "Recursos"
#: core/models.py:345
msgid "Resource access"
msgstr "Acceso a recursos"
#: core/models.py:346
msgid "Resource accesses"
msgstr "Accesos a recursos"
#: core/models.py:352
msgid "Resource access with this User and Resource already exists."
msgstr "Ya existe un acceso al recurso con este usuario y este recurso."
#: core/models.py:409
msgid "Visio room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:410
msgid "Values for Visio parameters to configure the room."
msgstr "Valores de los parámetros de videoconferencia para configurar la reunión."
#: core/models.py:417
msgid "Room PIN code"
msgstr "Código PIN de la reunión"
#: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Código único de n dígitos que identifica esta reunión en modo telefónico."
#: core/models.py:424 core/models.py:578
msgid "Room"
msgstr "Reunión"
#: core/models.py:425
msgid "Rooms"
msgstr "Reuniones"
#: core/models.py:589
msgid "Worker ID"
msgstr "ID del worker"
#: core/models.py:591
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr "Introduce un identificador para la grabación del worker. Este identificador se conserva incluso cuando el worker se detiene, lo que permite un seguimiento sencillo."
#: core/models.py:599
msgid "Recording mode"
msgstr "Modo de grabación"
#: core/models.py:600
msgid "Defines the mode of recording being called."
msgstr "Define el modo de grabación a utilizar."
#: core/models.py:605 core/models.py:606
msgid "Recording options"
msgstr "Opciones de grabación"
#: core/models.py:613
msgid "External Process ID"
msgstr "ID del proceso externo"
#: core/models.py:614
msgid "ID of the external process associated with the recording."
msgstr "ID del proceso externo asociado a la grabación."
#: core/models.py:620
msgid "Recording"
msgstr "Grabación"
#: core/models.py:621
msgid "Recordings"
msgstr "Grabaciones"
#: core/models.py:731
msgid "Recording/user relation"
msgstr "Relación grabación/usuario"
#: core/models.py:732
msgid "Recording/user relations"
msgstr "Relaciones grabación/usuario"
#: core/models.py:738
msgid "This user is already in this recording."
msgstr "Este usuario ya está en esta grabación."
#: core/models.py:744
msgid "This team is already in this recording."
msgstr "Este equipo ya está en esta grabación."
#: core/models.py:750
msgid "Either user or team must be set, not both."
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
#: core/models.py:767
msgid "Create rooms"
msgstr "Crear reunión"
#: core/models.py:768
msgid "List rooms"
msgstr "Listar reuniones"
#: core/models.py:769
msgid "Retrieve room details"
msgstr "Ver los detalles de una reunión"
#: core/models.py:770
msgid "Update rooms"
msgstr "Actualizar las reuniones"
#: core/models.py:771
msgid "Delete rooms"
msgstr "Eliminar las reuniones"
#: core/models.py:784
msgid "Application name"
msgstr "Nombre de la aplicación"
#: core/models.py:785
msgid "Descriptive name for this application."
msgstr "Nombre descriptivo de esta aplicación."
#: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo."
#: core/models.py:806
msgid "Application"
msgstr "Aplicación"
#: core/models.py:807
msgid "Applications"
msgstr "Aplicaciones"
#: core/models.py:830
msgid "Enter a valid domain"
msgstr "Introduce un dominio válido"
#: core/models.py:833
msgid "Domain"
msgstr "Dominio"
#: core/models.py:834
msgid "Email domain this application can act on behalf of."
msgstr "Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
#: core/models.py:846
msgid "Application domain"
msgstr "Dominio de aplicación"
#: core/models.py:847
msgid "Application domains"
msgstr "Dominios de aplicación"
#: core/models.py:865
msgid "Pending"
msgstr "Pendiente"
#: core/models.py:866
msgid "Analyzing"
msgstr "Analizando"
#: core/models.py:873
msgid "Ready"
msgstr "Listo"
#: core/models.py:879
msgid "Background image"
msgstr "Imagen de fondo"
#: core/models.py:891
msgid "title"
msgstr "título"
#: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Información sobre la detección de malware cuando el estado del análisis no es seguro."
#: core/models.py:920
msgid "File"
msgstr "Archivo"
#: core/models.py:921
msgid "Files"
msgstr "Archivos"
#: core/models.py:1041
msgid "This file is already hard deleted."
msgstr "Este archivo ya se ha eliminado definitivamente."
#: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted."
msgstr "Para eliminar definitivamente un archivo, primero debe haberse marcado como eliminado."
#: core/recording/event/notification.py:123
msgid "Your recording is ready"
msgstr "Tu grabación está lista"
#: core/recording/event/notification.py:194
msgid "Transcription"
msgstr "Transcripción"
#: core/recording/event/notification.py:204
#, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logotipo del correo"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "te invita a unirte a una videollamada en curso"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "UNIRSE A LA LLAMADA"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr "Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador para unirte a la llamada."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Consejos para una mejor experiencia:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Usa Chrome o Firefox para una mejor calidad de llamada"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Prueba tu micrófono y tu cámara antes de unirte"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Asegúrate de tener una conexión a internet estable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Gracias por usar %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "¡Tu grabación está lista!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr " Tu grabación de \"%(room_name)s\" del %(recording_date)s a las %(recording_time)s ya está lista para descargar. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " La grabación caducará dentro de %(days)s días. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr " Compartir la grabación mediante un enlace todavía no está disponible. Solo los organizadores pueden descargarla. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "Para conservar esta grabación de forma permanente:"
#: core/templates/mail/html/screen_recording.html:208
#, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Usa el botón \"Descargar\" de la interfaz "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Guarda el archivo en la ubicación que prefieras"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Abrir"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr " Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro equipo de soporte en %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo "
#: meet/settings.py:228
msgid "English"
msgstr "Inglés"
#: meet/settings.py:229
msgid "French"
msgstr "Francés"
#: meet/settings.py:230
msgid "Dutch"
msgstr "Neerlandés"
#: meet/settings.py:231
msgid "German"
msgstr "Alemán"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Español"
@@ -678,3 +678,7 @@ msgstr "Néerlandais"
#: meet/settings.py:231
msgid "German"
msgstr "Allemand"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Espagnol"
@@ -672,3 +672,7 @@ msgstr "Nederlands"
#: meet/settings.py:231
msgid "German"
msgstr "Duits"
#: meet/settings.py:233
msgid "Spanish"
msgstr "Spaans"
+1
View File
@@ -230,6 +230,7 @@ class Base(Configuration):
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
("es-es", _("Spanish")),
)
)
+1 -1
View File
@@ -3,6 +3,6 @@
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
"output": "src/locales/$LOCALE/$NAMESPACE.json",
"createOldCatalogs": false,
"locales": ["en", "fr", "de", "nl"],
"locales": ["en", "fr", "de", "nl", "es"],
"sort": true
}
+40 -35
View File
@@ -9,8 +9,8 @@
"version": "1.29.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@fontsource-variable/lexend": "5.3.0",
"@fontsource/opendyslexic": "5.3.0",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.23",
"@livekit/components-styles": "1.2.0",
@@ -29,7 +29,7 @@
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"posthog-js": "1.409.5",
"posthog-js": "1.414.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
@@ -776,18 +776,18 @@
}
},
"node_modules/@fontsource-variable/lexend": {
"version": "5.2.11",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.3.0.tgz",
"integrity": "sha512-3SXtiZ8rFbT0oaPzEboCG5RM3jtyhpNxandh1jWNLqvrJRfV/eP3R+GGw+o5e3hS1uqmAGutKxSvaWG5oyrimA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/opendyslexic": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
"integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.3.0.tgz",
"integrity": "sha512-BVYIW/ghc1U2iAghhYTN+GFWiZ3lnCy8Jf2KkZC23aHQHBNcMqKT718zckfqVxRx4qsXJmIMPWvczirRuhmtsg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
@@ -1720,28 +1720,28 @@
"license": "MIT"
},
"node_modules/@posthog/browser-common": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.3.1.tgz",
"integrity": "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.46.0",
"@posthog/types": "^1.399.0"
"@posthog/core": "^1.46.8",
"@posthog/types": "^1.402.0"
}
},
"node_modules/@posthog/core": {
"version": "1.48.3",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.405.0"
"@posthog/types": "^1.407.0"
}
},
"node_modules/@posthog/types": {
"version": "1.405.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
"version": "1.407.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
"license": "MIT"
},
"node_modules/@react-aria/overlays": {
@@ -4794,13 +4794,10 @@
}
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -9134,20 +9131,21 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.409.5",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.409.5.tgz",
"integrity": "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ==",
"version": "1.414.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
"license": "(Apache-2.0 AND MIT)",
"dependencies": {
"@posthog/browser-common": "^0.3.1",
"@posthog/core": "^1.46.1",
"@posthog/types": "^1.399.0",
"@posthog/browser-common": "^0.4.0",
"@posthog/core": "^1.46.9",
"@posthog/types": "^1.402.2",
"core-js": "^3.49.0",
"dompurify": "^3.3.2",
"dompurify": "^3.4.12",
"fflate": "^0.4.8",
"preact": "^10.29.3",
"query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0"
"web-vitals": "^5.3.0",
"web-vitals-soft-navs": "npm:web-vitals@6.0.0"
}
},
"node_modules/powershell-utils": {
@@ -11659,6 +11657,13 @@
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
"license": "Apache-2.0"
},
"node_modules/web-vitals-soft-navs": {
"name": "web-vitals",
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
"integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
"license": "Apache-2.0"
},
"node_modules/webrtc-adapter": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
+3 -3
View File
@@ -16,8 +16,8 @@
},
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5",
"@fontsource-variable/lexend": "5.3.0",
"@fontsource/opendyslexic": "5.3.0",
"@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.23",
"@livekit/components-styles": "1.2.0",
@@ -36,7 +36,7 @@
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"posthog-js": "1.409.5",
"posthog-js": "1.414.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
@@ -21,7 +21,9 @@ const StyledContainer = styled('div', {
})
export const ChatTextArea = () => {
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
const { isSending, send, textAreaValue } = useSnapshot(chatStore, {
sync: true,
})
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
@@ -49,7 +51,7 @@ export const ChatTextArea = () => {
const isDisabled = !textAreaValue.trim() || isSending
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.stopPropagation()
if (e.key !== 'Escape') e.stopPropagation()
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
return
e.preventDefault()
@@ -1,608 +0,0 @@
import { CSSProperties, useState } from 'react'
import { useConnectionState, useRoomContext } from '@livekit/components-react'
import { ConnectionState } from 'livekit-client'
import { StatsSnapshot, TrackRow, useWebRTCStats } from './useWebRTCStats'
import { readRoomConfig } from './roomConfig'
import {
forceTransport,
releaseForcedTransport,
Scenario,
SCENARIOS,
setDownlinkCap,
setUplinkCap,
stepBitrate,
TransportMode,
transportModeFromRoute,
} from './simulation'
const SANS =
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
const COLOR = {
text: '#e8e8e8',
muted: '#9a9a9a',
faint: '#6f6f6f',
hairline: '#2c2d31',
border: '#3d3e44',
surface: '#1b1c1e',
chartBg: '#141517',
down: '#f6821f',
up: '#5a9cf8',
ok: '#10b981',
bad: '#f05a4a',
busy: '#e5a13c',
}
const stateColor = (state: ConnectionState): string => {
switch (state) {
case ConnectionState.Connected:
return COLOR.ok
case ConnectionState.Reconnecting:
case ConnectionState.SignalReconnecting:
return COLOR.busy
case ConnectionState.Connecting:
return COLOR.up
case ConnectionState.Disconnected:
return COLOR.bad
}
}
const styles: Record<string, CSSProperties> = {
toggle: {
position: 'fixed',
bottom: 12,
right: 12,
zIndex: 9999,
fontFamily: SANS,
fontSize: 12,
lineHeight: 1,
padding: '8px 12px',
borderRadius: 999,
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.border,
background: COLOR.surface,
color: COLOR.text,
cursor: 'pointer',
boxShadow: '0 2px 10px #0006',
},
panel: {
position: 'fixed',
bottom: 12,
right: 12,
zIndex: 9999,
width: 460,
maxWidth: 'calc(100vw - 24px)',
maxHeight: 'calc(100vh - 24px)',
overflowY: 'auto',
fontFamily: SANS,
fontSize: 11,
lineHeight: 1.5,
color: COLOR.text,
background: COLOR.surface,
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.border,
borderRadius: 10,
padding: 14,
boxShadow: '0 10px 34px #00000080',
},
sectionTitle: {
margin: '14px 0 5px',
color: COLOR.faint,
textTransform: 'uppercase',
letterSpacing: 1.2,
fontSize: 9,
fontWeight: 600,
display: 'flex',
justifyContent: 'space-between',
},
metricsLine: {
display: 'flex',
alignItems: 'center',
gap: 16,
margin: '0 0 10px',
paddingBottom: 10,
borderBottom: `1px solid ${COLOR.hairline}`,
fontVariantNumeric: 'tabular-nums',
},
legend: {
display: 'flex',
gap: 12,
marginTop: 3,
color: COLOR.faint,
fontSize: 10,
},
list: { display: 'flex', flexDirection: 'column', maxHeight: '200px', overflowY: 'auto' },
listRow: { display: 'flex', gap: 8, padding: '1px 0' },
listLabel: { flex: '0 0 34%', color: COLOR.muted },
trackRow: {
display: 'flex',
gap: 8,
padding: '3px 0',
borderBottom: `1px solid ${COLOR.hairline}`,
alignItems: 'baseline',
},
trackLabel: {
flex: '0 0 34%',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
trackCodec: {
flex: '0 0 11%',
fontFamily: MONO,
fontSize: 10,
color: COLOR.muted,
},
trackKbps: {
flex: '0 0 9%',
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
},
trackDetail: { flex: 1, minWidth: 0 },
mono: { fontFamily: MONO, fontSize: 10, color: COLOR.muted },
button: {
fontFamily: SANS,
fontSize: 11,
padding: '3px 9px',
borderRadius: 5,
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.border,
background: '#232428',
color: COLOR.text,
cursor: 'pointer',
},
buttonActive: { borderColor: COLOR.down, color: COLOR.down },
buttonDisabled: { color: COLOR.faint, cursor: 'not-allowed' },
row: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
stepperValue: {
minWidth: 92,
textAlign: 'center',
borderWidth: 1,
borderStyle: 'solid',
borderColor: COLOR.hairline,
borderRadius: 4,
padding: '2px 6px',
background: COLOR.chartBg,
fontVariantNumeric: 'tabular-nums',
},
close: {
background: 'none',
border: 'none',
color: COLOR.faint,
cursor: 'pointer',
fontSize: 14,
},
}
const Sparkline = ({ history }: { history: StatsSnapshot[] }) => {
const width = 430
const height = 54
const max = Math.max(...history.map((s) => Math.max(s.upKbps, s.downKbps)), 1)
const points = (pick: (s: StatsSnapshot) => number) =>
history
.map(
(s, i) =>
`${((i / Math.max(history.length - 1, 1)) * width).toFixed(1)},${(
height -
(pick(s) / max) * (height - 6) -
3
).toFixed(1)}`
)
.join(' ')
return (
<>
<svg
width={width}
height={height}
role="img"
aria-label="Bandwidth over the last minute"
style={{
display: 'block',
background: COLOR.chartBg,
borderRadius: 6,
border: `1px solid ${COLOR.hairline}`,
}}
>
{history.length >= 2 && (
<>
<polyline
points={points((s) => s.downKbps)}
fill="none"
stroke={COLOR.down}
strokeWidth="1.5"
/>
<polyline
points={points((s) => s.upKbps)}
fill="none"
stroke={COLOR.up}
strokeWidth="1.5"
/>
</>
)}
</svg>
{/* Legend outside the plot: text over the lines was unreadable. */}
<div style={styles.legend}>
<span>
<span style={{ color: COLOR.down }}></span> down
</span>
<span>
<span style={{ color: COLOR.up }}></span> up
</span>
<span>last 60s · max {Math.round(max)} kbps</span>
</div>
</>
)
}
const TrackDetail = ({ track }: { track: TrackRow }) => {
const parts: Array<{ text: string; tone?: 'warn' | 'muted' }> = []
if (track.res) parts.push({ text: track.res })
if (track.fps !== undefined) {
parts.push({ text: `${Math.round(track.fps)}fps` })
}
if (track.lossPct !== undefined && track.lossPct > 0) {
parts.push({ text: `loss ${track.lossPct.toFixed(1)}%`, tone: 'warn' })
}
if (track.froze) parts.push({ text: 'freeze', tone: 'warn' })
if (track.limitation) {
parts.push({ text: `lim:${track.limitation}`, tone: 'warn' })
}
const layer = track.layer
if (layer) {
if (layer.availRes && layer.availRes !== track.res) {
parts.push({
text: `${layer.availRes}${layer.layerCount ? `(${layer.layerCount}L)` : ''}`,
tone: 'muted',
})
}
if (layer.reason === 'adaptive' && layer.elementRes) {
parts.push({ text: `fit:${layer.elementRes}`, tone: 'muted' })
} else if (layer.reason === 'bandwidth') {
parts.push({ text: 'bw-limited', tone: 'warn' })
} else if (layer.reason === 'paused') {
parts.push({ text: 'paused:SFU', tone: 'warn' })
} else if (layer.reason === 'off-screen') {
parts.push({ text: 'off-screen', tone: 'muted' })
}
}
return (
<>
{parts.map((part, i) => (
<span
key={part.text}
style={
part.tone === 'warn'
? { color: COLOR.bad }
: part.tone === 'muted'
? { color: COLOR.faint }
: {}
}
>
{i > 0 && ' · '}
{part.text}
</span>
))}
</>
)
}
const CapStepper = ({
label,
hint,
valueKbps,
onChange,
}: {
label: string
hint: string
valueKbps: number | null
onChange: (kbps: number | null) => void
}) => (
<div style={styles.row}>
<span style={{ flex: '0 0 70px' }}>{label}</span>
<button
type="button"
style={styles.button}
aria-label={`Decrease ${label} bandwidth`}
onClick={() => onChange(stepBitrate(valueKbps, 'decrease'))}
>
</button>
<span
style={{
...styles.stepperValue,
...(valueKbps !== null ? { color: COLOR.down } : {}),
}}
>
{valueKbps === null ? 'unlimited' : `${valueKbps} kbps`}
</span>
<button
type="button"
style={styles.button}
aria-label={`Increase ${label} bandwidth`}
onClick={() => onChange(stepBitrate(valueKbps, 'increase'))}
>
+
</button>
<span style={styles.mono}>{hint}</span>
</div>
)
const TrackList = ({ title, rows }: { title: string; rows: TrackRow[] }) => (
<>
<div style={styles.sectionTitle}>
<span>
{title} ({rows.length})
</span>
<span>
{Math.round(rows.reduce((sum, t) => sum + t.kbps, 0))} kbps media
</span>
</div>
<div style={styles.list}>
{rows.map((track) => (
<div key={track.key} style={styles.trackRow}>
<span style={styles.trackLabel}>{track.label}</span>
<span style={styles.trackCodec}>{track.codec ?? ''}</span>
<span style={styles.trackKbps}>{Math.round(track.kbps)}</span>
<span style={styles.trackDetail}>
<TrackDetail track={track} />
</span>
</div>
))}
{rows.length === 0 && <span style={{ color: COLOR.faint }}>none</span>}
</div>
</>
)
const MeetDevtools = () => {
const room = useRoomContext()
const connState = useConnectionState(room)
const [open, setOpen] = useState(false)
const [firedScenario, setFiredScenario] = useState<string>()
const [uplinkCap, setUplinkCapState] = useState<number | null>(null)
const [downlinkCap, setDownlinkCapState] = useState<number | null>(null)
const { snapshot, history } = useWebRTCStats(room, open)
const transportMode: TransportMode = transportModeFromRoute(snapshot?.route)
const fireScenario = (scenario: Scenario) => {
setFiredScenario(scenario.id)
scenario.run(room).catch((e) => {
console.warn('[MeetDevtools] simulateScenario failed', e)
})
window.setTimeout(
() =>
setFiredScenario((current) =>
current === scenario.id ? undefined : current
),
1200
)
}
if (!open) {
return (
<button
type="button"
style={styles.toggle}
onClick={() => setOpen(true)}
aria-label="Open WebRTC devtools"
>
<span style={{ color: stateColor(connState) }}></span> rtc
</button>
)
}
const config = readRoomConfig(room)
const published = snapshot?.tracks.filter((t) => t.dir === 'up') ?? []
const subscribed = snapshot?.tracks.filter((t) => t.dir === 'down') ?? []
const turnProtocols = snapshot?.turnProtocols ?? []
const transports: Array<{
mode: TransportMode
label: string
requires?: string
title: string
}> = [
{
mode: 'auto',
label: 'auto (udp)',
title:
'clears the server-cached transport preference and relay-only policy, then full reconnect',
},
{
mode: 'tcp',
label: 'tcp',
title: 'force-tcp — server prefers TCP candidates (ICE/TCP)',
},
{
mode: 'turn-udp',
label: 'turn:udp',
requires: 'udp',
title:
'client-side: iceTransportPolicy relay + full reconnect (no server hook exists)',
},
{
mode: 'turn-tcp',
label: 'turn:tcp',
requires: 'tcp',
title: 'force-tcp — lands on TURN/TCP when it is the TCP path',
},
{
mode: 'turn-tls',
label: 'turn:tls',
requires: 'tls',
title: 'force-tls — server switches you to TURN over TLS',
},
]
return (
<section style={styles.panel} aria-label="WebRTC devtools">
<div style={styles.metricsLine}>
<span>
<span style={{ color: COLOR.down }}></span> {snapshot?.downKbps ?? 0}{' '}
kbps
</span>
<span>
<span style={{ color: COLOR.up }}></span> {snapshot?.upKbps ?? 0}{' '}
kbps
</span>
<span style={{ color: COLOR.muted }}>
rtt{' '}
<span style={{ color: COLOR.text }}>{snapshot?.rttMs ?? ''}</span> ms
</span>
<span style={{ color: COLOR.muted }}>
jitter{' '}
<span style={{ color: COLOR.text }}>{snapshot?.jitterMs ?? ''}</span>{' '}
ms
</span>
<span
style={{
color: stateColor(connState),
fontSize: 10,
marginLeft: 'auto',
}}
>
{connState}
</span>
<button
type="button"
style={styles.close}
onClick={() => setOpen(false)}
aria-label="Close WebRTC devtools"
>
</button>
</div>
<Sparkline history={history} />
<div style={styles.sectionTitle}>room configuration (live)</div>
<div style={styles.list}>
{config.map((entry) => (
<div key={entry.label} style={styles.listRow}>
<span
style={{
color:
entry.on === undefined
? COLOR.faint
: entry.on
? COLOR.ok
: COLOR.bad,
}}
>
</span>
<span style={styles.listLabel}>{entry.label}</span>
<span>{entry.value}</span>
</div>
))}
</div>
<TrackList title="published ↑" rows={published} />
<TrackList title="subscribed ↓" rows={subscribed} />
<div style={styles.sectionTitle}>bandwidth</div>
<div style={{ ...styles.list, gap: 4 }}>
<CapStepper
label="uplink"
hint="encoder cap (setParameters)"
valueKbps={uplinkCap}
onChange={(kbps) => {
setUplinkCapState(kbps)
void setUplinkCap(room, kbps)
}}
/>
<CapStepper
label="downlink"
hint="SFU limit (subscriber-bandwidth)"
valueKbps={downlinkCap}
onChange={(kbps) => {
setDownlinkCapState(kbps)
void setDownlinkCap(room, kbps).catch((e) =>
console.warn('[MeetDevtools] downlink cap failed', e)
)
}}
/>
</div>
<div style={styles.sectionTitle}>transport</div>
<div style={styles.row}>
{transports.map(({ mode, label, requires, title }) => {
const available = !requires || turnProtocols.includes(requires)
const active = transportMode === mode
const clickable = available && !active
return (
<button
key={mode}
type="button"
aria-pressed={active}
disabled={!clickable}
title={available ? title : `${title} — not configured`}
style={{
...styles.button,
...(clickable || active ? {} : styles.buttonDisabled),
...(active ? styles.buttonActive : {}),
}}
onClick={() => {
if (!clickable) return
void (
mode === 'auto'
? releaseForcedTransport(room)
: forceTransport(
room,
mode as 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
)
).catch((e) =>
console.warn('[MeetDevtools] transport change failed', e)
)
}}
>
{requires && (
<span style={{ color: available ? COLOR.ok : COLOR.faint }}>
{' '}
</span>
)}
{label}
</button>
)
})}
<span style={styles.mono}>
route:{' '}
{snapshot?.route
? [
snapshot.route.protocol,
snapshot.route.type,
snapshot.route.relayProtocol &&
`relay:${snapshot.route.relayProtocol}`,
]
.filter(Boolean)
.join('·')
: ''}
</span>
</div>
<div style={styles.sectionTitle}>connection scenarios</div>
<div style={styles.row}>
{SCENARIOS.map((scenario) => (
<button
key={scenario.id}
type="button"
title={`${scenario.side}-side simulation — momentary, watch the state dot`}
style={{
...styles.button,
...(firedScenario === scenario.id ? styles.buttonActive : {}),
}}
onClick={() => fireScenario(scenario)}
>
{scenario.side === 'server' ? '☁ ' : ''}
{scenario.label}
</button>
))}
</div>
</section>
)
}
export default MeetDevtools
@@ -1,15 +0,0 @@
import { lazy, Suspense } from 'react'
const LazyPanel = import.meta.env.DEV
? lazy(() => import('./MeetDevtools'))
: null
export const MeetDevtools = () => {
if (!LazyPanel) return null
return (
<Suspense fallback={null}>
<LazyPanel />
</Suspense>
)
}
@@ -1,89 +0,0 @@
import { Room } from 'livekit-client'
export type ConfigEntry = {
label: string
value: string
/** true = feature actively on, false = off, undefined = informational */
on?: boolean
}
const formatBackupCodec = (backup: unknown): string => {
if (backup === undefined || backup === true) return 'auto'
if (backup === false) return 'off'
if (typeof backup === 'object' && backup !== null && 'codec' in backup) {
return String((backup as { codec: unknown }).codec)
}
return String(backup)
}
export const readRoomConfig = (room: Room): ConfigEntry[] => {
const options = room.options
const publish = options.publishDefaults
const adaptive = options.adaptiveStream
const entries: ConfigEntry[] = [
{
label: 'adaptiveStream',
value:
typeof adaptive === 'object'
? JSON.stringify(adaptive)
: String(!!adaptive),
on: !!adaptive,
},
{
label: 'dynacast',
value: String(!!options.dynacast),
on: !!options.dynacast,
},
{
label: 'e2ee',
value: String(room.isE2EEEnabled),
on: room.isE2EEEnabled,
},
{
label: 'videoCodec',
value: publish?.videoCodec ?? 'default',
},
{
label: 'backupCodec',
value: formatBackupCodec(publish?.backupCodec),
},
{
label: 'simulcast',
value: String(publish?.simulcast ?? true),
on: publish?.simulcast ?? true,
},
{
label: 'audio dtx',
value: String(publish?.dtx ?? true),
on: publish?.dtx ?? true,
},
{
label: 'audio red',
value: String(publish?.red ?? true),
on: publish?.red ?? true,
},
{
label: 'quality (local)',
value: room.localParticipant.connectionQuality,
},
]
const server = room.serverInfo
if (server) {
entries.push({
label: 'server',
value: [
server.version && `v${server.version}`,
server.region,
server.protocol !== undefined && `proto ${server.protocol}`,
server.edition !== undefined && `edition ${server.edition}`,
]
.filter(Boolean)
.join(' · '),
})
}
return entries
}
@@ -1,193 +0,0 @@
import { Room, Track } from 'livekit-client'
// Transitive dependency of livekit-client (pinned by it); used only to
// build the one signal request room.simulateScenario cannot express.
import { SimulateScenario } from '@livekit/protocol'
/** Shared bandwidth ladder for the /+ steppers; null = unlimited. */
export const BITRATE_LADDER_KBPS: Array<number | null> = [
null,
2000,
1000,
600,
300,
150,
]
export const stepBitrate = (
current: number | null,
direction: 'decrease' | 'increase'
): number | null => {
const index = BITRATE_LADDER_KBPS.indexOf(current)
const safeIndex = index === -1 ? 0 : index
const next =
direction === 'decrease'
? Math.min(safeIndex + 1, BITRATE_LADDER_KBPS.length - 1)
: Math.max(safeIndex - 1, 0)
return BITRATE_LADDER_KBPS[next]
}
const savedEncodings = new WeakMap<RTCRtpSender, Array<number | undefined>>()
export const setUplinkCap = async (
room: Room,
kbps: number | null
): Promise<void> => {
const senders: RTCRtpSender[] = []
room.localParticipant.trackPublications.forEach((pub) => {
const track = pub.track
if (track?.kind === Track.Kind.Video && track.sender) {
senders.push(track.sender)
}
})
for (const sender of senders) {
const params = sender.getParameters()
if (!params.encodings || params.encodings.length === 0) continue
if (kbps === null) {
const original = savedEncodings.get(sender)
params.encodings.forEach((encoding, i) => {
encoding.maxBitrate = original?.[i]
})
savedEncodings.delete(sender)
} else {
if (!savedEncodings.has(sender)) {
savedEncodings.set(
sender,
params.encodings.map((encoding) => encoding.maxBitrate)
)
}
const activeCount =
params.encodings.filter((encoding) => encoding.active !== false)
.length || 1
// Split the budget across active simulcast layers / SVC encoding.
const perEncoding = Math.max(
30_000,
Math.floor((kbps * 1000) / activeCount)
)
params.encodings.forEach((encoding) => {
encoding.maxBitrate = perEncoding
})
}
try {
await sender.setParameters(params)
} catch (e) {
console.warn('[MeetDevtools] setParameters failed', e)
}
}
}
export const setDownlinkCap = (
room: Room,
kbps: number | null
): Promise<void> =>
room.simulateScenario('subscriber-bandwidth', kbps === null ? 0 : kbps * 1000)
export type Scenario = {
id: string
label: string
/** where the simulation happens */
side: 'client' | 'server'
run: (room: Room) => Promise<void>
}
export const SCENARIOS: Scenario[] = [
{
id: 'resume',
label: 'reconnect (resume)',
side: 'client',
// Replays a signaling WebSocket loss; media keeps flowing, client resumes.
run: (room) => room.simulateScenario('signal-reconnect'),
},
{
id: 'resume-fail',
label: 'reconnect (resume fails)',
side: 'client',
// Same, but the next resume attempt fails → exercises the retry ladder.
run: (room) => room.simulateScenario('resume-reconnect'),
},
{
id: 'full-reconnect',
label: 'full reconnect',
side: 'client',
// Complete rejoin with brand-new peer connections.
run: (room) => room.simulateScenario('full-reconnect'),
},
{
id: 'migration',
label: 'server migration',
side: 'server',
run: (room) => room.simulateScenario('migration'),
},
{
id: 'node-failure',
label: 'SFU node failure',
side: 'server',
run: (room) => room.simulateScenario('node-failure'),
},
{
id: 'server-leave',
label: 'server disconnect',
side: 'server',
// Server-initiated leave: the closest thing to "you got kicked".
run: (room) => room.simulateScenario('server-leave'),
},
]
export type TransportMode =
| 'auto'
| 'tcp'
| 'turn-udp'
| 'turn-tcp'
| 'turn-tls'
const clearServerTransportPreference = (room: Room): Promise<void> =>
room.engine.client.sendSimulateScenario(
new SimulateScenario({
scenario: { case: 'switchCandidateProtocol', value: 0 },
})
)
const setRelayOnly = (room: Room, relay: boolean) => {
room.engine.rtcConfig = {
...room.engine.rtcConfig,
iceTransportPolicy: relay ? 'relay' : 'all',
}
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 300))
export const forceTransport = async (
room: Room,
mode: 'tcp' | 'turn-udp' | 'turn-tcp' | 'turn-tls'
): Promise<void> => {
if (mode === 'turn-udp') {
await clearServerTransportPreference(room)
setRelayOnly(room, true)
await settle()
return room.simulateScenario('full-reconnect')
}
setRelayOnly(room, false)
return room.simulateScenario(mode === 'turn-tls' ? 'force-tls' : 'force-tcp')
}
export const releaseForcedTransport = async (room: Room): Promise<void> => {
setRelayOnly(room, false)
await clearServerTransportPreference(room)
await settle()
await room.simulateScenario('full-reconnect')
}
export const transportModeFromRoute = (route?: {
protocol?: string
relayProtocol?: string
}): TransportMode => {
// relayProtocol is the client→TURN leg; protocol alone means no relay.
if (route?.relayProtocol === 'tls') return 'turn-tls'
if (route?.relayProtocol === 'tcp') return 'turn-tcp'
if (route?.relayProtocol === 'udp') return 'turn-udp'
if (route?.protocol === 'tcp') return 'tcp'
return 'auto'
}
@@ -1,357 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { Room, Track } from 'livekit-client'
export type LayerInfo = {
/** top published layer, e.g. '1280x720' */
availRes?: string
/** number of published spatial layers (simulcast/SVC), when known */
layerCount?: number
/** largest attached element size in device px, e.g. '480x270' */
elementRes?: string
/** why the forwarded layer is below the top one */
reason?: 'adaptive' | 'bandwidth' | 'paused' | 'off-screen'
}
export type TrackRow = {
key: string
dir: 'up' | 'down'
kind: string
label: string
codec?: string
kbps: number
fps?: number
res?: string
lossPct?: number
limitation?: string
/** subscribed video only: SFU layer forwarding context */
layer?: LayerInfo
/** this track froze during the last tick */
froze?: boolean
}
export type StatsSnapshot = {
ts: number
/** wire totals from transport stats (includes headers, RTCP, FEC) */
upKbps: number
downKbps: number
rttMs?: number
/** worst inbound RTP jitter across subscribed tracks */
jitterMs?: number
availableOutKbps?: number
/** selected ICE route of the publisher transport (measured, not assumed) */
route?: { protocol?: string; type?: string; relayProtocol?: string }
/**
* relayProtocol values of gathered relay local candidates — i.e. which
* client→TURN transports are actually configured (udp/tcp/tls). Empty
* when no TURN server is configured.
*/
turnProtocols: string[]
tracks: TrackRow[]
}
type StatDict = Record<string, unknown>
type Counters = Record<string, number>
const asNumber = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined
const asString = (v: unknown): string | undefined =>
typeof v === 'string' ? v : undefined
const shortCodec = (mimeType?: string) =>
mimeType ? mimeType.replace(/^(audio|video)\//, '') : undefined
type TrackContext = { label: string; layer?: LayerInfo; publishedH?: number }
/**
* msTrackId -> label + subscribed-layer context, rebuilt on every tick.
* The "why is this tile blurry" answer needs SDK state (publication +
* element size) joined with getStats (forwarded resolution); this is the
* SDK-state half.
*/
const buildTrackContext = (room: Room) => {
const contexts = new Map<string, TrackContext>()
room.localParticipant.trackPublications.forEach((pub) => {
const id = pub.track?.mediaStreamTrack?.id
if (id) contexts.set(id, { label: `local ${pub.source}` })
})
const dpr = window.devicePixelRatio || 1
room.remoteParticipants.forEach((participant) => {
// Keep rows scannable: participant names capped at 10 chars.
const rawName = participant.name || participant.identity
const name = rawName.length > 15 ? `${rawName.slice(0, 15)}.` : rawName
participant.trackPublications.forEach((pub) => {
const track = pub.track
const id = track?.mediaStreamTrack?.id
if (!track || !id) return
const label = `${name} ${pub.source}`
if (track.kind !== Track.Kind.Video) {
contexts.set(id, { label })
return
}
let elementRes: string | undefined
let elementH: number | undefined
for (const element of track.attachedElements) {
const w = Math.round(element.clientWidth * dpr)
const h = Math.round(element.clientHeight * dpr)
if (elementH === undefined || h > elementH) {
elementH = h
elementRes = `${w}x${h}`
}
}
const dims = pub.dimensions
const layers = pub.trackInfo?.layers?.length
contexts.set(id, {
label,
publishedH: dims?.height,
layer: {
availRes: dims ? `${dims.width}x${dims.height}` : undefined,
layerCount: layers && layers > 1 ? layers : undefined,
elementRes,
reason: !pub.isEnabled
? 'off-screen'
: track.streamState === Track.StreamState.Paused
? 'paused'
: undefined,
},
})
})
})
return contexts
}
/** Decide why a forwarded layer is below the published top layer. */
const resolveLayerReason = (
context: TrackContext,
forwardedH: number | undefined
): LayerInfo | undefined => {
const layer = context.layer
if (!layer) return undefined
if (layer.reason) return layer // off-screen / paused already decided
const publishedH = context.publishedH
if (!publishedH || !forwardedH || forwardedH >= publishedH * 0.9) {
return { ...layer, reason: undefined } // full quality, nothing to explain
}
// Below top layer: if the element only needs about what we get, it's
// adaptiveStream fitting the element; otherwise the SFU is holding back
// a layer the element could use — congestion.
const elementH = layer.elementRes
? Number(layer.elementRes.split('x')[1])
: undefined
const adaptive = elementH !== undefined && forwardedH >= elementH * 0.7
return { ...layer, reason: adaptive ? 'adaptive' : 'bandwidth' }
}
export const useWebRTCStats = (
room: Room,
enabled: boolean,
intervalMs = 1000
) => {
// Single source of truth: the snapshot is just the last history entry.
const [history, setHistory] = useState<StatsSnapshot[]>([])
const prevRef = useRef(new Map<string, Counters>())
useEffect(() => {
if (!enabled) return
let cancelled = false
const prev = prevRef.current
/** Per-stat counter deltas; returns 0 on the first sighting. */
const deltas = (key: string, now: Counters): Counters => {
const before = prev.get(key)
prev.set(key, now)
const out: Counters = {}
for (const [name, value] of Object.entries(now)) {
out[name] = before?.[name] !== undefined ? value - before[name] : 0
}
return out
}
const collect = async () => {
const reports: Array<{ pc: 'pub' | 'sub'; report: RTCStatsReport }> = []
try {
// Not public API — see file header.
const manager = room.engine?.pcManager
const pub = await manager?.publisher?.getStats()
if (pub) reports.push({ pc: 'pub', report: pub })
const sub = await manager?.subscriber?.getStats()
if (sub) reports.push({ pc: 'sub', report: sub })
} catch {
// Engine not ready or SDK internals changed; panel shows nothing.
}
if (cancelled || reports.length === 0) return
const contexts = buildTrackContext(room)
const tracks: TrackRow[] = []
let upKbps = 0
let downKbps = 0
let rttMs: number | undefined
let jitterMs: number | undefined
let availableOutKbps: number | undefined
let route: StatsSnapshot['route']
const turnProtocols = new Set<string>()
for (const { pc, report } of reports) {
const byId = new Map<string, StatDict>()
report.forEach((stat) => byId.set(stat.id as string, stat as StatDict))
report.forEach((raw) => {
const stat = raw as StatDict
const type = asString(stat.type)
const ts = asNumber(stat.timestamp) ?? Date.now()
const key = `${pc}:${asString(stat.id) ?? ''}`
if (
type === 'local-candidate' &&
asString(stat.candidateType) === 'relay'
) {
// Relay candidates are only gathered when a TURN server is
// configured and reachable; relayProtocol says how the client
// reaches it (udp/tcp/tls) — a turn:…?transport=udp server
// must NOT be presented as a TURN/TLS capability.
turnProtocols.add(
asString(stat.relayProtocol) ?? asString(stat.protocol) ?? 'udp'
)
}
if (type === 'transport') {
const d = deltas(key, {
sent: asNumber(stat.bytesSent) ?? 0,
received: asNumber(stat.bytesReceived) ?? 0,
ts,
})
if (d.ts > 0) {
upKbps += Math.max(0, (d.sent * 8) / d.ts)
downKbps += Math.max(0, (d.received * 8) / d.ts)
}
}
if (type === 'candidate-pair' && stat.nominated === true) {
const rtt = asNumber(stat.currentRoundTripTime)
if (rtt !== undefined) rttMs = Math.round(rtt * 1000)
const available = asNumber(stat.availableOutgoingBitrate)
if (available !== undefined && pc === 'pub') {
availableOutKbps = Math.round(available / 1000)
}
if (pc === 'pub') {
const local = byId.get(asString(stat.localCandidateId) ?? '')
route = {
protocol: asString(local?.protocol),
type: asString(local?.candidateType),
relayProtocol: asString(local?.relayProtocol),
}
}
}
if (type === 'outbound-rtp' || type === 'inbound-rtp') {
const isUp = type === 'outbound-rtp'
const kind = asString(stat.kind)
if (!isUp) {
const jitter = asNumber(stat.jitter)
if (jitter !== undefined) {
const ms = Math.round(jitter * 1000)
if (jitterMs === undefined || ms > jitterMs) jitterMs = ms
}
}
// Packet loss: reported directly on inbound-rtp; for outbound it
// lives on the matching remote-inbound-rtp (what the SFU got).
let packetsLost = asNumber(stat.packetsLost) ?? 0
if (isUp) {
const remote = byId.get(asString(stat.remoteId) ?? '')
packetsLost = asNumber(remote?.packetsLost) ?? 0
}
const d = deltas(key, {
bytes: asNumber(isUp ? stat.bytesSent : stat.bytesReceived) ?? 0,
packets:
asNumber(isUp ? stat.packetsSent : stat.packetsReceived) ?? 0,
packetsLost,
ts,
freezeCount: asNumber(stat.freezeCount) ?? 0,
})
const kbps = d.ts > 0 ? Math.max(0, (d.bytes * 8) / d.ts) : 0
const lostTotal = d.packetsLost + d.packets
const lossPct =
lostTotal > 0
? Math.max(0, Math.min(100, (d.packetsLost / lostTotal) * 100))
: 0
// Resolve codec + source track.
const codecStat = byId.get(asString(stat.codecId) ?? '')
let msTrackId = asString(stat.trackIdentifier)
if (!msTrackId && isUp) {
const mediaSource = byId.get(asString(stat.mediaSourceId) ?? '')
msTrackId = asString(mediaSource?.trackIdentifier)
}
const rid = asString(stat.rid)
const context = msTrackId ? contexts.get(msTrackId) : undefined
const baseLabel =
context?.label ??
`${kind ?? 'media'} ssrc ${asNumber(stat.ssrc) ?? '?'}`
const width = asNumber(stat.frameWidth)
const height = asNumber(stat.frameHeight)
tracks.push({
key,
dir: isUp ? 'up' : 'down',
kind: kind ?? 'unknown',
label: rid ? `${baseLabel} [${rid}]` : baseLabel,
codec: shortCodec(asString(codecStat?.mimeType)),
kbps,
fps: asNumber(stat.framesPerSecond),
res: width && height ? `${width}x${height}` : undefined,
lossPct,
limitation:
asString(stat.qualityLimitationReason) === 'none'
? undefined
: asString(stat.qualityLimitationReason),
layer:
!isUp && kind === 'video' && context
? resolveLayerReason(context, height)
: undefined,
froze: !isUp && kind === 'video' && d.freezeCount > 0,
})
}
})
}
// Stable order (direction, then label): sorting by bitrate would
// reshuffle rows on every tick as kbps fluctuates.
tracks.sort((a, b) =>
a.dir === b.dir
? a.label.localeCompare(b.label)
: a.dir === 'up'
? -1
: 1
)
const next: StatsSnapshot = {
ts: Date.now(),
upKbps: Math.round(upKbps),
downKbps: Math.round(downKbps),
rttMs,
jitterMs,
availableOutKbps,
route,
turnProtocols: Array.from(turnProtocols).sort(),
tracks,
}
setHistory((h) => [...h.slice(-59), next])
}
void collect()
const id = window.setInterval(() => void collect(), intervalMs)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [room, enabled, intervalMs])
return { snapshot: history[history.length - 1], history }
}
@@ -43,7 +43,6 @@ import { useSnapshot } from 'valtio'
import { userPreferencesStore } from '@/stores/userPreferences'
import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
import { MeetDevtools } from '@/features/devtools'
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
export const Conference = ({
@@ -299,7 +298,6 @@ export const Conference = ({
<VideoConference />
{!isMobile && <InviteDialog mode={mode} />}
<PictureInPictureConference />
<MeetDevtools />
</LiveKitRoom>
</Screen>
</QueryAware>
@@ -1,5 +1,7 @@
import { useLocalParticipant } from '@livekit/components-react'
import { TrackSource } from '@livekit/protocol'
import { useEffect } from 'react'
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
@@ -7,6 +9,8 @@ export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
export type MediaStateChangedDetail = {
microphoneEnabled: boolean
cameraEnabled: boolean
canPublishMicrophone: boolean
canPublishCamera: boolean
}
/**
@@ -16,9 +20,15 @@ export type MediaStateChangedDetail = {
*
* const el = document.getElementById('media-state')
* new MutationObserver(...).observe(el, { attributes: true })
*
* The publish permissions are exposed alongside the state: an external tool
* cannot tell a muted microphone from one it is not allowed to unmute, and
* would otherwise offer a control that silently does nothing.
*/
export const MediaStateObserver = () => {
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
const canPublishMicrophone = useCanPublishTrack(TrackSource.MICROPHONE)
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
useEffect(() => {
window.dispatchEvent(
@@ -26,10 +36,17 @@ export const MediaStateObserver = () => {
detail: {
microphoneEnabled: isMicrophoneEnabled,
cameraEnabled: isCameraEnabled,
canPublishMicrophone,
canPublishCamera,
},
})
)
}, [isMicrophoneEnabled, isCameraEnabled])
}, [
isMicrophoneEnabled,
isCameraEnabled,
canPublishMicrophone,
canPublishCamera,
])
return (
<div
@@ -37,6 +54,8 @@ export const MediaStateObserver = () => {
style={{ display: 'none' }}
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
data-can-publish-microphone={canPublishMicrophone ? 'true' : 'false'}
data-can-publish-camera={canPublishCamera ? 'true' : 'false'}
/>
)
}
@@ -16,6 +16,8 @@ import { Info } from './Info'
import { HStack } from '@/styled-system/jsx'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { useEscapeToClose } from '@/hooks/useEscapeToClose'
import { srOnly } from '@/styles/a11y'
type StyledSidePanelProps = {
title: string
@@ -24,6 +26,7 @@ type StyledSidePanelProps = {
onClose: () => void
isClosed: boolean
closeButtonTooltip: string
escapeHint: string
isSubmenu: boolean
onBack: () => void
backButtonLabel: string
@@ -40,6 +43,7 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
isClosed,
isReactionToolbarOpen,
closeButtonTooltip,
escapeHint,
isSubmenu = false,
onBack,
backButtonLabel,
@@ -84,7 +88,11 @@ const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
}}
aria-hidden={isClosed}
aria-label={ariaLabel}
aria-describedby="side-panel-escape-hint"
>
<span id="side-panel-escape-hint" className={srOnly}>
{escapeHint}
</span>
<HStack alignItems="center">
{isSubmenu && (
<Button
@@ -194,6 +202,8 @@ export const SidePanel = () => {
activeKey: activePanelId,
})
useEscapeToClose(isSidePanelOpen, asideRef, closeSidePanel)
return (
<StyledSidePanel
ref={asideRef}
@@ -203,6 +213,7 @@ export const SidePanel = () => {
closeButtonTooltip={t('closeButton', {
content: t(`content.${activeSubPanelId || activePanelId}`),
})}
escapeHint={t('escapeHint')}
isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen}
@@ -0,0 +1,26 @@
import { useEffect, useRef, type RefObject } from 'react'
export const useEscapeToClose = (
isActive: boolean,
containerRef: RefObject<HTMLElement | null>,
onClose: () => void
) => {
const onCloseRef = useRef(onClose)
useEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
if (!isActive) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
if (!containerRef.current?.contains(document.activeElement)) return
e.stopPropagation()
onCloseRef.current()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isActive, containerRef])
}
+1 -1
View File
@@ -15,7 +15,7 @@ i18n
.use(initReactI18next)
.use(LanguageDetector)
.init({
supportedLngs: ['en', 'fr', 'nl', 'de'],
supportedLngs: ['en', 'fr', 'nl', 'de', 'es'],
fallbackLng,
ns: i18nDefaultNamespace,
detection: {
@@ -5,6 +5,7 @@ const languageLabels: Record<string, string> = {
fr: 'Français',
de: 'Deutsch',
nl: 'Nederlands',
es: 'Español',
}
export const useLanguageLabels = () => {
+2 -1
View File
@@ -435,7 +435,8 @@
"tools": "Weitere Tools",
"info": "Meeting-Informationen"
},
"closeButton": "{{content}} ausblenden"
"closeButton": "{{content}} ausblenden (Escape)",
"escapeHint": "Escape drücken zum Schließen"
},
"chat": {
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
+2 -1
View File
@@ -435,7 +435,8 @@
"tools": "more tools",
"info": "meeting information"
},
"closeButton": "Hide {{content}}"
"closeButton": "Hide {{content}} (Escape)",
"escapeHint": "Press Escape to close"
},
"chat": {
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call.",
@@ -0,0 +1,34 @@
{
"accessibility": {
"title": "Accesibilidad",
"introduction": "<i>Visio</i> se compromete a hacer accesibles sus servicios digitales, de conformidad con el artículo 47 de la ley n.º 2005-102 de 11 de febrero de 2005.",
"declaration": {
"title": "Declaración de accesibilidad",
"date": "Redactada el 4 de diciembre de 2024."
},
"scope": "Esta declaración de accesibilidad se aplica al sitio visio.numerique.gouv.fr",
"complianceStatus": {
"title": "Estado de conformidad",
"body": "visio.numerique.gouv.fr no es conforme con el RGAA 4.1. El sitio todavía no ha sido auditado. No obstante, el equipo trabaja para crear un sitio accesible para todo el mundo siguiendo las recomendaciones del RGAA."
},
"improvement": {
"title": "Mejora y contacto",
"body": "Si no consigues acceder a un contenido o a un servicio, puedes ponerte en contacto con el responsable de lasuite.numerique.gouv.fr para que te oriente hacia una alternativa accesible u obtener el contenido en otro formato.",
"contact": {
"email": "Correo electrónico: visio@numerique.gouv.fr",
"address": "Dirección: DINUM, 20 avenue de Ségur 75007 París"
},
"response": "Intentamos responder en un plazo de 2 días laborales."
},
"recourse": {
"title": "Vía de recurso",
"introduction": "Este procedimiento debe utilizarse en el siguiente caso: has comunicado al responsable del sitio web un defecto de accesibilidad que te impide acceder a un contenido o a uno de los servicios del portal y no has obtenido una respuesta satisfactoria.",
"options": {
"intro": "Puedes:",
"option1": "Escribir un mensaje al Defensor de Derechos",
"option2": "Ponerte en contacto con el delegado del Defensor de Derechos de tu región",
"option3": "Enviar una carta por correo postal (gratuito, sin sello): </br> Défenseur des droits Libre réponse 71120 75342 Paris CEDEX 07"
}
}
}
}
@@ -0,0 +1,56 @@
{
"title": "Prueba tu configuración",
"runTest": "Iniciar la prueba",
"runAgain": "Repetir",
"cancel": "Cancelar",
"detailsFor": "Detalles del paso {{step}}",
"downloadReport": "Descargar el informe",
"homeLink": "Prueba tu configuración",
"progress": "{{done}}/{{total}}",
"progressLabel": "Progreso de la prueba de conexión",
"groups": {
"local": "Navegador y dispositivos",
"network": "Conexión al servidor"
},
"steps": {
"browser": "Navegador",
"microphone": "Micrófono",
"camera": "Cámara",
"devices": "Dispositivos multimedia",
"websocket": "WebSocket",
"webrtc": "WebRTC",
"turn": "TURN",
"reconnect": "Reconexión",
"selectedCandidate": "Ruta seleccionada",
"publishAudio": "Publicación de audio",
"publishVideo": "Publicación de vídeo"
},
"status": {
"pending": "En espera",
"running": "En curso…",
"success": "Correcto",
"failed": "Error",
"skipped": "Omitido"
},
"counts": {
"passed": "correctas",
"failed": "con errores",
"skipped": "omitidas"
},
"summary": {
"idle": "Listo para la prueba",
"idleHint": "La prueba dura aproximadamente un minuto. Tu cámara y tu micrófono solo se utilizan durante la prueba.",
"running": "Prueba en curso…",
"runningHint": "Mantén esta página abierta hasta que terminen las comprobaciones.",
"passed": "Todo funciona",
"passedHint": "Tu navegador, tus dispositivos y tu red están listos para una reunión.",
"partial": "Prueba parcial",
"partialHint": "Se han omitido algunas comprobaciones. Autoriza el acceso a tu cámara y a tu micrófono para probarlos.",
"failed_one": "{{count}} verificación en error",
"failed_other": "{{count}} verificaciones en error",
"failedHint": "Abre las verificaciones en error para ver el detalle y transmite después el informe a tu servicio informático."
},
"help": {
"firewall": "Si las pruebas de red fallan, comprueba los permisos de tu navegador y las reglas de filtrado de red (WebRTC, WebSocket, TURN) con tu servicio informático."
}
}
+62
View File
@@ -0,0 +1,62 @@
{
"backToHome": "Volver al inicio",
"cancel": "Cancelar",
"closeDialog": "Cerrar la ventana modal",
"error": {
"heading": "Se ha producido un error al cargar la página"
},
"feedback": {
"context": "¡Tu opinión es fundamental!",
"cta": "Comparte tu opinión - nueva ventana"
},
"forbidden": {
"heading": "Acceso denegado"
},
"loading": "Cargando…",
"loggedInUserTooltip": "Con la sesión iniciada como ",
"login": {
"buttonLabel": "Iniciar sesión",
"proconnectButtonLabel": "Identificarse con ProConnect",
"proconnectLinkLabel": "¿Qué es ProConnect? - nueva ventana",
"proconnectLink": "¿Qué es ProConnect?"
},
"logout": "Cerrar sesión",
"notFound": {
"heading": "Comprueba tu código de reunión",
"body": "Comprueba que has introducido el código de reunión correcto en la URL. Ejemplo:"
},
"selected": "seleccionado",
"submit": "Aceptar",
"footer": {
"links": {
"legifrance": "legifrance.gouv.fr",
"infogouv": "info.gouv.fr",
"servicepublic": "service-public.fr",
"datagouv": "data.gouv.fr",
"legalsTerms": "Aviso legal",
"data": "Datos personales y cookies",
"accessibility": "Accesibilidad: no conforme",
"connectionTest": "Prueba tu configuración",
"ariaLabel": "nueva ventana",
"codeAnnotation": "Nuestro código es abierto y está disponible en este",
"code": "repositorio de código abierto",
"technicalDetails": "Ficha técnica",
"termsOfService": "Condiciones de uso"
},
"mentions": "Salvo que se indique lo contrario, los contenidos de este sitio están disponibles bajo",
"license": "licencia etalab 2.0"
},
"loginHint": {
"title": "Inicia sesión con tu cuenta",
"body": "En lugar de esperar, inicia sesión con tu cuenta.",
"button": {
"ariaLabel": "Cerrar la sugerencia",
"label": "Aceptar"
}
},
"skipLink": "Ir al contenido principal",
"clipboardContent": {
"url": "Para participar en la videoconferencia, haz clic en este enlace: {{roomUrl}}",
"numberAndPin": "Para participar por teléfono, marca el {{phoneNumber}} e introduce este código: {{pinCode}}"
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"createMeeting": "Crear una reunión",
"heading": "Videoconferencias sencillas y seguras",
"intro": "Comunícate y trabaja con facilidad, manteniendo siempre el control de tus datos",
"joinInputError": "Introduce un enlace o un código de reunión. Ejemplos:",
"joinInputExample": "Un código de reunión tiene este aspecto: abc-defg-hij",
"joinInputLabel": "Enlace completo o código de la reunión",
"joinInputSubmit": "Unirse a la reunión",
"joinMeeting": "Unirse a una reunión",
"joinMeetingTipContent": "Puedes unirte a una reunión copiando directamente su enlace completo en la barra de direcciones del navegador.",
"joinMeetingTipHeading": "Consejo",
"loginToCreateMeeting": "Inicia sesión para crear una reunión",
"moreLinkLabel": "Más información sobre {{appTitle}} - nueva ventana",
"moreLink": "Más información",
"moreAbout": "sobre {{appTitle}}",
"connectionTestLink": "Prueba tu configuración",
"createMenu": {
"laterOption": "Crear una reunión para más tarde",
"instantOption": "Iniciar una reunión ahora"
},
"laterMeetingDialog": {
"heading": "Tus datos de conexión",
"description": "Comparte estos datos con los invitados. Podrán unirse a la reunión sin necesidad de iniciar sesión. Esta reunión es permanente y puede reutilizarse.",
"permissions": "Las personas que dispongan de este enlace no necesitan tu autorización para unirse a esta reunión.",
"copy": "Copiar los datos",
"copied": "Copiados en el portapapeles",
"copyUrl": "Copiar el enlace de la reunión",
"phone": {
"call": "Llama al:",
"pinCode": "Código:"
}
},
"introSlider": {
"carouselLabel": "Presentación de diapositivas",
"previous": {
"label": "Diapositiva anterior",
"labelWithPosition": "Diapositiva anterior ({{current}} de {{total}})",
"tooltip": "Diapositiva anterior"
},
"next": {
"label": "Diapositiva siguiente",
"labelWithPosition": "Diapositiva siguiente ({{current}} de {{total}})",
"tooltip": "Diapositiva siguiente"
},
"slidePosition": "Diapositiva {{current}} de {{total}}",
"beta": {
"text": "Probar la beta",
"tooltip": "Acceder al formulario"
},
"slide1": {
"title": "Pásate a la sencillez. ¡Pruébanos ahora mismo!",
"body": "Descubre una solución intuitiva y accesible, pensada para el sector público y todos sus colaboradores."
},
"slide2": {
"title": "Organiza llamadas de grupo sin límite",
"body": "Reuniones sin límite de tiempo, con una comunicación fluida y de alta calidad, sea cual sea el número de participantes."
},
"slide3": {
"title": "Transforma tus reuniones con la IA",
"body": "Obtén transcripciones precisas y accionables para impulsar tu productividad. Función experimental, ¡pruébala ahora!"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"title": "Aviso legal",
"creator": {
"title": "Editor",
"body": "El servicio Visio está editado por la Dirección Interministerial de lo Digital del Estado (DINUM), con domicilio en:",
"contact": {
"title": "Datos de contacto",
"address": "20 avenue de Ségur",
"city": "75007 París",
"phone": "Tel. Recepción: 01.71.21.01.70",
"siret": "SIRET: 12000101100010 (secretaría general del gobierno)",
"siren": "SIREN: 120 001 011"
}
},
"director": {
"title": "Director de la publicación",
"body": "La directora de la publicación es la señora Stéphanie Schaer, directora interministerial de lo digital."
},
"hosting": {
"title": "Alojamiento",
"body": "El servicio está alojado por Outscale West-1 CloudGouv SecNumCloud, situado en Francia."
},
"accessibility": {
"title": "Accesibilidad",
"body": "La conformidad con las normas de accesibilidad digital es un objetivo posterior. El sitio todavía no ha sido auditado. No obstante, el equipo trabaja para crear un sitio accesible para todo el mundo siguiendo las recomendaciones del RGAA.",
"more": "Para saber más: ",
"link": "enlace a la página de accesibilidad",
"status": "Estado de accesibilidad: no conforme"
},
"reuse": {
"title": "Reutilización de los contenidos y enlaces",
"body1": "Salvo mención explícita de propiedad intelectual perteneciente a terceros, los contenidos de este sitio se ofrecen bajo ",
"license": "licencia abierta Etalab 2.0",
"body2": "En particular, eres libre de reproducirlos, copiarlos, modificarlos, extraerlos, transformarlos, comunicarlos, difundirlos, redistribuirlos, publicarlos, transmitirlos y explotarlos siempre que menciones su fuente y su fecha de última actualización, y no induzcas a error a terceros sobre la información que contienen.",
"body3": "Todo sitio público o privado está autorizado a establecer, sin autorización previa, un enlace (incluido un enlace profundo) hacia la información difundida en este sitio."
}
}
@@ -0,0 +1,66 @@
{
"defaultName": "Un colaborador",
"joined": {
"description": "{{name}} participa en la llamada"
},
"raised": {
"description": "{{name}} ha levantado la mano.",
"cta": "Abrir la lista de espera"
},
"muted": "{{name}} ha silenciado tu micrófono. Ningún participante puede oírte.",
"openChat": "Abrir el chat",
"chatMessageReceived": "{{name}} dice: {{message}}",
"lowerHand": {
"auto": "Parece que has tomado la palabra, así que se va a bajar la mano.",
"dismiss": "Dejar la mano levantada"
},
"autoMuteLargeRoom": {
"auto": "Acabas de unirte a una reunión numerosa. Tu micrófono se ha silenciado automáticamente.",
"dismiss": "Reactivar el micrófono"
},
"reaction": {
"description": "{{name}} ha reaccionado con {{emoji}}"
},
"permissionsRemoved": {
"camera": "El organizador ha desactivado el vídeo de todos los participantes.",
"microphone": "El organizador ha desactivado el micrófono de todos los participantes.",
"screen_share": "El organizador ha desactivado la pantalla compartida de todos los participantes."
},
"waitingParticipants": {
"one": "Una persona quiere participar en esta llamada.",
"several": "Varias personas quieren participar en esta llamada.",
"open": "Mostrar",
"accept": "Aceptar"
},
"transcript": {
"started": "{{name}} ha iniciado la transcripción de la reunión.",
"stopped": "{{name}} ha detenido la transcripción de la reunión.",
"limitReached": "La transcripción ha superado la duración máxima permitida, se va a guardar automáticamente.",
"requested": "{{name}} ha solicitado iniciar la transcripción."
},
"screenRecording": {
"started": "{{name}} ha iniciado la grabación de la reunión.",
"stopped": "{{name}} ha detenido la grabación de la reunión.",
"limitReached": "La grabación ha superado la duración máxima permitida, se va a guardar automáticamente.",
"requested": "{{name}} ha solicitado iniciar la grabación."
},
"recordingSave": {
"transcript": {
"message": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en <strong>{{email}}</strong> en cuanto la transcripción esté lista.",
"default": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en cuanto la transcripción esté lista."
},
"screenRecording": {
"message": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en <strong>{{email}}</strong> en cuanto esté lista.",
"default": "¡Estamos finalizando tu grabación! Recibirás un correo electrónico en cuanto esté lista."
}
},
"roleChanged": {
"body": "Tu rol ha cambiado, ahora eres {{role}}.",
"roles": {
"owner": "Organizador",
"administrator": "Coorganizador",
"member": "Miembro"
}
},
"openMenu": "Abrir el menú"
}
@@ -0,0 +1,28 @@
{
"error": {
"title": "Grabación no disponible",
"body": "No se encuentra la grabación. Solo el organizador de la reunión puede acceder a ella. No dudes en ponerte en contacto con él si lo necesitas."
},
"expired": {
"title": "Grabación caducada",
"body": "Esta grabación se eliminó el {{date}}."
},
"authentication": {
"title": "Autenticación necesaria",
"body": "Inicia sesión para acceder a esta grabación."
},
"unsaved": {
"title": "Descarga no disponible",
"body": "Esta grabación aún no está lista para descargarse. Inténtalo de nuevo más tarde."
},
"success": {
"title": "¡Tu grabación está lista!",
"body": "Grabación de la reunión {{room}} del {{created_at}}.",
"expiration": "Atención: esta grabación caducará al cabo de {{expiration_days}} día(s).",
"button": "Descargar",
"warning": {
"title": "Uso compartido por enlace desactivado",
"body": "Compartir la grabación mediante un enlace todavía no está disponible. Solo los organizadores pueden descargarla."
}
}
}
+801
View File
@@ -0,0 +1,801 @@
{
"feedback": {
"heading": {
"normal": "Has salido de la reunión",
"duplicateIdentity": "Te has unido a la reunión desde otro dispositivo",
"participantRemoved": "Un administrador te ha expulsado de la llamada"
},
"home": "Volver al inicio",
"back": "Volver a la reunión"
},
"selectDevice": {
"loading": "Cargando…",
"select": "Selecciona un valor",
"permissionsNeeded": "Permisos necesarios",
"deviceNotFound": {
"videoinput": "No se ha detectado ninguna cámara. Comprueba que esté bien conectada.",
"audioinput": "No se ha detectado ningún micrófono. Comprueba que esté bien conectado."
},
"deviceInUse": {
"videoinput": "Cámara no disponible: probablemente la esté usando otra aplicación u otra pestaña.",
"audioinput": "Micrófono no disponible: probablemente lo esté usando otra aplicación u otra pestaña."
},
"settings": {
"audio": "Configuración de audio",
"video": "Configuración de vídeo"
},
"effects": "Fondos y efectos",
"videoinput": {
"choose": "Elegir la cámara web",
"permissionsNeeded": "Elegir la cámara web - permisos necesarios",
"disable": "Desactivar la cámara web",
"enable": "Activar la cámara web",
"turnedOff": "Cámara web desactivada",
"turnedOn": "Cámara web activada",
"label": "Cámara web",
"placeholder": "Activa la cámara web para previsualizar la imagen"
},
"audioinput": {
"choose": "Elegir el micrófono",
"level": "Nivel de entrada del micrófono",
"muteTest": "Activa tu micrófono para probarlo",
"permissionsNeeded": "Elegir el micrófono - permisos necesarios",
"disable": "Desactivar el micrófono",
"enable": "Activar el micrófono",
"turnedOff": "Micrófono desactivado",
"turnedOn": "Micrófono activado",
"label": "Micrófono"
},
"audiooutput": {
"choose": "Elegir el altavoz",
"permissionsNeeded": "Elegir el altavoz - permisos necesarios",
"test": "Probar los altavoces",
"testing": "Reproduciendo el sonido de prueba…"
}
},
"join": {
"heading": "¿Unirse a la reunión?",
"effects": {
"description": "Fondos y efectos",
"title": "Fondos y efectos",
"subTitle": "Configura los efectos de tu cámara."
},
"joinLabel": "Unirse",
"joinMeeting": "Unirse a la reunión",
"toggleOff": "Haz clic para desactivar",
"toggleOn": "Haz clic para activar",
"usernameHint": "Se muestra a los demás participantes",
"usernameLabel": "Tu nombre",
"errors": {
"usernameEmpty": "Tu nombre no puede estar vacío"
},
"cameraDisabled": "La cámara está desactivada.",
"cameraNotFound": "No se ha detectado ninguna cámara. Comprueba que esté bien conectada.",
"cameraInUse": "Tu cámara no está disponible. Probablemente la esté usando otra aplicación u otra pestaña.",
"cameraStarting": "La cámara va a iniciarse…",
"cameraNotGranted": "¿Quieres que los demás puedan verte durante la reunión?",
"cameraAndMicNotGranted": "¿Quieres que los demás puedan verte y oírte durante la reunión?",
"videoPreview": {
"enabled": "Vista previa de vídeo activada",
"disabled": "Vista previa de vídeo desactivada"
},
"permissionsButton": {
"cameraAndMicNotGranted": "Autoriza el acceso a la cámara y al micrófono",
"cameraNotGranted": "Autoriza el acceso a la cámara"
},
"waiting": {
"title": "Solicitud de participación…",
"body": "Podrás participar en esta llamada cuando alguien te lo autorice"
},
"denied": {
"title": "No puedes participar en esta llamada",
"body": "Tu solicitud de participación ha sido rechazada."
},
"timeoutInvite": {
"title": "No puedes participar en esta llamada",
"body": "Nadie ha respondido a tu solicitud de participación en la llamada"
}
},
"leaveRoomPrompt": "Volver al inicio hará que salgas de la reunión.",
"shareDialog": {
"copy": "Copiar los datos",
"copyUrl": "Copiar el enlace",
"copied": "Copiados en el portapapeles",
"heading": "Tu reunión está lista",
"description": "Comparte este enlace con las personas que quieras invitar a la reunión.",
"permissions": "Las personas que dispongan de este enlace no necesitan tu autorización para unirse a esta reunión.",
"closeDialog": "Cerrar la ventana modal",
"phone": {
"call": "Llama al:",
"pinCode": "Código:"
}
},
"pagination": {
"label": "Páginas de participantes",
"count": "{{currentPage}} de {{totalPageCount}}",
"next": "página siguiente",
"previous": "página anterior"
},
"mediaErrorDialog": {
"DeviceInUse": {
"title": {
"audioinput": "No se puede activar el micrófono",
"videoinput": "No se puede activar la cámara"
},
"body": {
"audioinput": "Tu micrófono ya se está usando en otra pestaña o en otra aplicación, lo que impide activarlo en esta videoconferencia. Si quieres, cierra la otra pestaña o aplicación para usarlo aquí.",
"videoinput": "Tu cámara ya se está usando en otra pestaña o en otra aplicación, lo que impide activarla en esta videoconferencia. Si quieres, cierra la otra pestaña o aplicación para usarla aquí."
}
},
"NotFound": {
"title": {
"audioinput": "No se ha detectado ningún micrófono",
"videoinput": "No se ha detectado ninguna cámara"
},
"body": {
"audioinput": "No se ha detectado ningún micrófono en tu dispositivo. Comprueba que esté bien conectado y que ningún botón lo silencie, y vuelve a intentarlo.",
"videoinput": "No se ha detectado ninguna cámara en tu dispositivo. Comprueba que esté bien conectada y que ninguna tapa o botón la bloquee, y vuelve a intentarlo."
}
},
"close": "Aceptar"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} no tiene permiso para usar tu cámara",
"microphone": "{{appTitle}} no tiene permiso para usar tu micrófono",
"cameraAndMicrophone": "{{appTitle}} no tiene permiso para usar tu micrófono ni tu cámara",
"default": "{{appTitle}} no tiene permiso para usar algunas funciones necesarias."
},
"body": {
"openMenu": {
"others": "Haz clic en el icono de configuración ICON_PLACEHOLDER en la barra de direcciones de tu navegador",
"safari": "Haz clic en el menú \"Safari\" y abre \"Configuración para {{appDomain}}\"."
},
"details": {
"camera": "Autoriza el acceso a la cámara",
"microphone": "Autoriza el acceso al micrófono",
"cameraAndMicrophone": "Autoriza el acceso a la cámara y al micrófono",
"default": "Activa los permisos necesarios."
}
}
},
"systemPermissionDialog": {
"heading": {
"camera": "Autoriza a tu navegador a usar tu cámara",
"microphone": "Autoriza a tu navegador a usar tu micrófono",
"cameraAndMicrophone": "Autoriza a tu navegador a usar tu cámara y tu micrófono"
},
"device": {
"camera": "cámara",
"microphone": "micrófono",
"cameraAndMicrophone": "cámara y tu micrófono"
},
"intro": "Tu navegador ya tiene el permiso, pero la configuración de tu ordenador bloquea el acceso a tu {{device}}.",
"steps": {
"macos": {
"1": "En los ajustes del Sistema, abre «Privacidad y seguridad».",
"2": "Autoriza a tu navegador a acceder a tu {{device}}."
},
"windows": {
"1": "En la configuración de Windows, abre «Privacidad y seguridad».",
"2": "Activa el acceso a tu {{device}} y autoriza a las aplicaciones de escritorio a usarlo."
},
"android": {
"1": "En los ajustes de Android, abre «Aplicaciones» y selecciona tu navegador.",
"2": "En «Permisos», autoriza el acceso a tu {{device}} y recarga la página."
},
"other": {
"1": "En los ajustes de privacidad de tu sistema, autoriza a tu navegador a acceder a tu {{device}}.",
"2": "Después, vuelve a intentarlo."
}
},
"openSettings": "Abrir los ajustes del sistema"
},
"silentMic": {
"tooltip": "Tu micrófono está activado, pero no se detecta ningún sonido. Haz clic para saber más.",
"dialog": {
"title": "No se detecta ningún sonido",
"intro": "Tu micrófono está activado, pero hace un rato que no se detecta ningún sonido. Comprueba los siguientes puntos:",
"causes": {
"system": "Puede que el acceso al micrófono esté bloqueado en los ajustes de privacidad de tu sistema.",
"hardware": "Puede que un botón o interruptor silencie el micrófono, o que esté desconectado o defectuoso.",
"wrongDevice": "Puede que haya otro micrófono seleccionado. Prueba a elegir otro en el menú del micrófono."
},
"hint": "Vuelve a hablar después de comprobar estos puntos. El aviso desaparecerá en cuanto se detecte un sonido.",
"close": "Entendido",
"discard": "Desactivar esta detección"
}
},
"permissionsButton": {
"tooltip": "Más información",
"ariaLabel": "Problema de permisos. Mostrar más información"
},
"error": {
"createRoom": {
"heading": "Autenticación necesaria",
"body": "Esta reunión todavía no se ha creado. Autentícate para crearla o espera a que lo haga un usuario autenticado."
},
"screenShare": {
"title": "No se puede compartir tu pantalla",
"ariaLabel": "No se puede compartir tu pantalla",
"message": "Puede que tu navegador no tenga permiso para grabar la pantalla en tu ordenador.",
"settingsInstructions": "Accede a tus",
"settingsLabel": {
"macos": "Preferencias del sistema",
"windows": "ajustes de privacidad de Windows"
},
"helpLinkText": "Para saber más, consulta",
"helpLinkLabel": "Problema de presentación",
"closeButton": "Ignorar",
"newTab": "Nueva ventana"
}
},
"isIdleDisconnectModal": {
"title": "¿Sigues ahí?",
"body": "Eres el único participante. Por eso, esta llamada terminará dentro de {{duration}}. ¿Quieres continuar la llamada?",
"settingsPrefix": "Para no volver a ver este mensaje, ve a",
"settingsLink": "Configuración > General",
"settingsSuffix": ".",
"stayButton": "Continuar la llamada",
"leaveButton": "Salir ahora",
"countdownAnnouncement": "La llamada termina dentro de {{duration}}."
},
"controls": {
"region": "Controles de la llamada",
"microphone": "Micrófono",
"camera": "Cámara",
"chat": {
"open": "Ocultar el chat",
"closed": "Mostrar el chat",
"input": {
"textArea": {
"label": "Escribir un mensaje",
"placeholder": "Escribir un mensaje"
},
"button": {
"label": "Enviar un mensaje"
}
}
},
"info": {
"open": "Ocultar la información",
"closed": "Mostrar la información"
},
"hand": {
"raise": "Levantar la mano",
"lower": "Bajar la mano"
},
"subtitles": {
"closed": "Mostrar los subtítulos",
"open": "Ocultar los subtítulos"
},
"screenShare": {
"start": "Compartir pantalla",
"stop": "Dejar de compartir"
},
"leave": "Salir",
"participants": {
"open": "Ocultar los participantes",
"closed": "Mostrar los participantes",
"count_one": "{{count}} participante",
"count_other": "{{count}} participantes"
},
"tools": {
"open": "Ocultar las herramientas de reunión",
"closed": "Mostrar las herramientas de reunión"
},
"admin": {
"open": "Ocultar la administración",
"closed": "Mostrar la administración"
},
"moreOptions": "Más opciones",
"reactions": {
"button": "Enviar una reacción",
"toolbar": "Enviar una reacción",
"announce": "{{name}}: {{emoji}}",
"you": "tú",
"emojis": {
"thumbs-up": "pulgar hacia arriba",
"thumbs-down": "pulgar hacia abajo",
"clapping-hands": "aplausos",
"red-heart": "corazón",
"face-with-tears-of-joy": "cara llorando de risa",
"face-with-open-mouth": "cara de asombro",
"party-popper": "confeti",
"folded-hands": "manos juntas"
}
}
},
"pictureInPicture": {
"placeholder": {
"title": "Tu videollamada está en otra ventana.",
"description": "El modo imagen en imagen te permite seguir conectado a la llamada mientras haces otras tareas.",
"bringBack": "Traer la llamada aquí"
},
"stage": "Participantes",
"controlBar": "Controles de la reunión",
"title": "Reunión en imagen en imagen"
},
"options": {
"buttonLabel": "Más opciones",
"items": {
"feedback": "Comparte tu opinión",
"settings": "Configuración",
"screenRecording": "Grabación",
"transcript": "Transcripción",
"username": "Elegir tu nombre",
"effects": "Fondos y efectos",
"switchCamera": "Cambiar de cámara",
"pictureInPicture": {
"enter": "Imagen en imagen",
"exit": "Cerrar la imagen en imagen"
},
"fullscreen": {
"enter": "Pantalla completa",
"exit": "Salir del modo de pantalla completa"
},
"support": "Contactar con el soporte",
"documentation": "Documentación"
}
},
"effects": {
"activateCamera": "Tu cámara está desactivada. Elige una opción para activarla.",
"cameraDisabled": "Tu cámara está desactivada.",
"notAvailable": "Los efectos de vídeo estarán disponibles pronto en tu navegador. ¡Estamos trabajando en ello! Mientras tanto, puedes usar Google Chrome para un mejor rendimiento, o Firefox :(",
"heading": "Desenfoque",
"clear": "Desactivar el efecto",
"blur": {
"title": "Desenfoque del fondo",
"status": {
"none": "Fondo nítido.",
"light": "Fondo ligeramente desenfocado.",
"strong": "Fondo muy desenfocado."
},
"light": {
"apply": "Desenfocar el fondo",
"clear": "Desactivar el desenfoque"
},
"normal": {
"apply": "Desenfocar aún más el fondo",
"clear": "Desactivar el desenfoque"
}
},
"virtual": {
"title": "Fondos virtuales",
"selectedLabel": "Fondo aplicado:",
"apply": "Sustituye tu fondo:",
"personal": {
"title": "Mis fondos",
"selectedLabel": "Dejar de usar como fondo (actualmente activo).",
"apply": "Usar como fondo.",
"selectFileTooltip": "Selecciona una imagen para usarla como fondo",
"notLoggedInWarning": "No has iniciado sesión, el fondo personal no se guardará de una reunión a otra.",
"warningUploadDisabled": "Actualmente los fondos personales no se guardan de una reunión a otra.",
"uploadLimitReached": "No puedes añadir más fondos personales.",
"uploadInProgress": "Enviando la imagen…",
"deleteLabel": "Eliminar el fondo.",
"errors": {
"close": "Cerrar",
"file_too_large": {
"title": "Archivo demasiado grande",
"description": "El archivo es demasiado grande. Elige un archivo de menos de {{maxSize, number}} MB."
},
"invalid_file_type": {
"title": "Tipo de archivo no válido",
"description": "El tipo de archivo no es compatible. Elige un archivo {{allowedExtension}}."
}
}
},
"presets": {
"title": "Sugerencias",
"descriptions": {
"0": "Mueble acanalado de madera",
"1": "Sala de reuniones",
"2": "Loft con cristalera negra",
"3": "Comedor",
"4": "Estanterías de madera",
"5": "Escalera de madera",
"6": "Estantería gris",
"7": "Barra de cafetería"
}
}
},
"faceLandmarks": {
"title": "Efectos visuales",
"glasses": {
"apply": "Añadir gafas",
"clear": "Quitar las gafas"
},
"french": {
"apply": "Añadir el toque francés",
"clear": "Quitar el toque francés"
}
}
},
"sidePanel": {
"ariaLabel": "Panel lateral - {{title}}",
"backToTools": "Volver a las herramientas de reunión",
"heading": {
"participants": "Participantes",
"effects": "Fondos y efectos",
"chat": "Mensajes de la llamada",
"transcript": "Transcribir",
"screenRecording": "Grabar",
"admin": "Controles del organizador",
"tools": "Herramientas de reunión",
"info": "Información sobre la reunión"
},
"content": {
"participants": "los participantes",
"effects": "los efectos",
"chat": "los mensajes",
"transcript": "transcribir",
"screenRecording": "grabar",
"admin": "controles del organizador",
"tools": "herramientas de reunión",
"info": "información sobre la reunión"
},
"closeButton": "Ocultar {{content}}"
},
"chat": {
"disclaimer": "Los mensajes solo son visibles para los participantes en el momento de\nsu envío. Todos los mensajes se eliminan al final de la llamada.",
"messagesLog": "Mensajes del chat"
},
"moreTools": {
"body": "Accede a más herramientas para mejorar tus reuniones.",
"linkAriaLabel": "Abrir la documentación sobre las herramientas - se abre en una nueva ventana",
"moreLink": "Abrir la documentación",
"tools": {
"transcript": {
"title": "Transcribir",
"body": "Transcribir la reunión."
},
"screenRecording": {
"title": "Grabar",
"body": "Grabar la reunión en vídeo."
}
}
},
"info": {
"roomInformation": {
"title": "Datos de conexión",
"button": {
"ariaLabel": "Copiar los datos de tu reunión",
"copy": "Copiar los datos",
"copied": "Datos copiados"
},
"phone": {
"call": "Llama al:",
"pinCode": "Código:"
}
}
},
"transcript": {
"heading": "Transcribe tu reunión con el Asistente de IA",
"body": "Transcribe hasta {{max_duration}} de reunión.",
"bodyWithoutMaxDuration": "Transcribe tu reunión sin límite.",
"details": {
"receiver": "La transcripción se enviará al organizador y a los coorganizadores.",
"destination": "Se creará un nuevo documento en",
"destinationUnknown": "Se creará un nuevo documento",
"language": "Idioma de la reunión:",
"recording": "Iniciar también una grabación"
},
"button": {
"start": "Empezar a transcribir la reunión",
"stop": "Dejar de transcribir la reunión",
"saving": "Guardando…",
"starting": "Iniciando…",
"anotherModeStarted": "Hay una grabación en curso. <br/> Cambia de menú y deténla."
},
"linkMore": "Abrir la documentación",
"linkAriaLabel": "Abrir la documentación sobre la transcripción - se abre en una nueva ventana",
"notAdminOrOwner": {
"heading": "Acceso restringido",
"body": "Por razones de seguridad, solo el creador o un administrador de la reunión puede iniciar una transcripción (beta).",
"linkMore": "Abrir la documentación",
"linkAriaLabel": "Abrir la documentación sobre el acceso a la transcripción - se abre en una nueva ventana",
"dividerLabel": "O",
"login": {
"heading": "Inicio de sesión necesario",
"body": "Solo el creador de la reunión o un administrador puede iniciar la transcripción. Inicia sesión para comprobar tus permisos."
},
"request": {
"heading": "Pedírselo al organizador",
"body": "El anfitrión recibirá una notificación y podrá iniciar la transcripción por ti.",
"buttonLabel": "Solicitar"
}
},
"premium": {
"heading": "Función avanzada",
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
"linkMore": "Abrir la documentación",
"dividerLabel": "O",
"login": {
"heading": "¡No has iniciado sesión!",
"body": "Debes haber iniciado sesión para usar esta función. Inicia sesión y vuelve a intentarlo."
},
"request": {
"heading": "Pedírselo al organizador",
"body": "El anfitrión recibirá una notificación y podrá iniciar la transcripción por ti.",
"buttonLabel": "Solicitar"
},
"linkAriaLabel": "Abrir la documentación sobre la función avanzada - se abre en una nueva ventana"
}
},
"screenRecording": {
"heading": "Graba esta llamada para más tarde",
"body": "Graba hasta {{max_duration}} de reunión.",
"bodyWithoutMaxDuration": "Graba tu reunión sin límite.",
"linkMore": "Abrir la documentación",
"linkAriaLabel": "Abrir la documentación sobre la grabación - se abre en una nueva ventana",
"details": {
"receiver": "La grabación se enviará al organizador y a los coorganizadores.",
"destination": "Esta grabación se conservará temporalmente en nuestros servidores",
"loading": "Iniciando la grabación",
"linkMore": "Más información",
"transcription": "Transcribir esta grabación"
},
"button": {
"start": "Empezar a grabar la reunión",
"stop": "Dejar de grabar la reunión",
"saving": "Guardando…",
"starting": "Iniciando…",
"anotherModeStarted": "Hay una transcripción en curso. <br/> Cambia de menú y deténla."
},
"notAdminOrOwner": {
"heading": "Acceso restringido",
"body": "Por razones de seguridad, solo el creador o un administrador de la reunión puede iniciar una grabación (beta).",
"linkMore": "Abrir la documentación",
"linkAriaLabel": "Abrir la documentación sobre el acceso a la grabación - se abre en una nueva ventana",
"dividerLabel": "O",
"login": {
"heading": "Inicio de sesión necesario",
"body": "Solo el creador de la reunión o un administrador puede iniciar la grabación. Inicia sesión para comprobar tus permisos."
},
"request": {
"heading": "Pedírselo al organizador",
"body": "El anfitrión recibirá una notificación y podrá iniciar la grabación por ti.",
"buttonLabel": "Solicitar"
}
},
"premium": {
"heading": "Función avanzada",
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
"linkMore": "Abrir la documentación",
"dividerLabel": "O",
"login": {
"heading": "¡No has iniciado sesión!",
"body": "Debes haber iniciado sesión para usar esta función. Inicia sesión y vuelve a intentarlo."
},
"request": {
"heading": "Pedírselo al organizador",
"body": "El anfitrión recibirá una notificación y podrá iniciar la grabación por ti.",
"buttonLabel": "Solicitar"
},
"linkAriaLabel": "Abrir la documentación sobre la función avanzada - se abre en una nueva ventana"
},
"durationMessage": "(limitado a {{max_duration}}) "
},
"errorRecordingAlertDialog": {
"title": "Error de grabación",
"body": {
"stop": "No hemos podido detener la grabación. Inténtalo de nuevo dentro de unos instantes.",
"start": "No hemos podido iniciar la grabación. Inténtalo de nuevo dentro de unos instantes."
},
"button": "Aceptar"
},
"admin": {
"description": "Esta configuración de organizador te permite mantener el control de tu reunión. Solo los organizadores pueden acceder a estos controles.",
"access": {
"title": "Acceso a la reunión",
"description": "Esta configuración se aplicará también a las futuras ediciones de esta reunión.",
"type": "Tipo de acceso a la reunión",
"levels": {
"public": {
"label": "Abierta",
"description": "Cualquier persona puede unirse a la reunión sin autorización."
},
"trusted": {
"label": "Abierta a personas de confianza",
"description": "Las personas autenticadas no tienen que pedir permiso para unirse a la reunión."
},
"restricted": {
"label": "Restringida",
"description": "Las personas que no hayan sido invitadas a la reunión deben pedir unirse a ella."
}
}
},
"moderation": {
"title": "Moderación de la reunión",
"description": "Esta configuración limita las acciones que pueden realizar los colaboradores durante la reunión.",
"microphone": {
"label": "Encender el micrófono",
"description": ""
},
"camera": {
"label": "Activar el vídeo",
"description": ""
},
"screenshare": {
"label": "Compartir su pantalla",
"description": "Si desactivas esta opción, los participantes ya no podrán compartir su pantalla y cualquier pantalla compartida en curso se interrumpirá de inmediato."
},
"mute": {
"label": "Silenciar a los demás",
"description": "Si desactivas esta opción, los participantes ya no podrán silenciar a otros participantes."
}
}
},
"rating": {
"submit": "Enviar",
"question": "¿Qué te ha parecido la calidad de tu llamada?",
"levels": {
"min": "muy mala",
"max": "excelente"
}
},
"openFeedback": {
"question": "¿Qué podemos hacer para mejorar?",
"placeholder": "Describe los errores o comparte tus sugerencias…",
"submit": "Enviar",
"skip": "Omitir"
},
"confirmationMessage": {
"heading": "Gracias por tu opinión",
"body": "Nuestro equipo de producto se toma el tiempo de analizar atentamente tus respuestas. Nos pondremos en contacto contigo lo antes posible."
},
"authenticationMessage": {
"heading": "¿Cómo podemos ponernos en contacto contigo?",
"placeholder": "Tu correo electrónico",
"submit": "Enviar",
"ignore": "Ignorar"
},
"participants": {
"subheading": "En la reunión",
"you": "Tú",
"unknown": "Participante desconocido",
"contributors": "Colaboradores",
"host": "Organizador de la reunión",
"cohost": "Coorganizador",
"member": "Miembro",
"collapsable": {
"open": "Abrir la lista {{name}}",
"close": "Cerrar la lista {{name}}"
},
"unauthenticated": {
"badge": "Usuario no autenticado"
},
"muteYourself": "Silenciar tu micrófono",
"muteParticipant": "Silenciar el micrófono de {{name}}",
"muteParticipantAlert": {
"heading": "Silenciar el micrófono de {{name}}",
"description": "¿Silenciar el micrófono de {{name}} para todos los participantes? {{name}} es la única persona autorizada a reactivar su micrófono",
"confirm": "Silenciar el micrófono",
"cancel": "Cancelar"
},
"raisedHands": "Manos levantadas",
"lowerParticipantHand": "Bajar la mano de {{name}}",
"lowerParticipantsHand": "Bajar todas las manos",
"muteParticipants": "Silenciar todos los micrófonos",
"waiting": {
"title": "Sala de espera",
"accept": {
"button": "Aceptar",
"label": "Aceptar a {{name}} en la reunión",
"all": "Aceptar todo"
},
"deny": {
"button": "Rechazar",
"label": "Rechazar a {{name}} en la reunión",
"all": "Rechazar todo"
}
},
"moreOptions": "Más opciones"
},
"participantMenu": {
"remove": {
"label": "Expulsar de la llamada",
"ariaLabel": "Expulsar a {{name}} de la llamada"
},
"unpin": {
"label": "Dejar de fijar",
"ariaLabel": "Dejar de fijar a {{name}}"
},
"pin": {
"label": "Fijar",
"ariaLabel": "Fijar a {{name}}"
},
"promote": {
"label": "Nombrar coorganizador",
"ariaLabel": "Nombrar coorganizador a {{name}}"
},
"demote": {
"label": "Retirar el rol de coorganizador",
"ariaLabel": "Retirar el rol de coorganizador a {{name}}"
}
},
"pinAnnouncements": {
"pin": "El vídeo de {{name}} está fijado.",
"unpin": "El vídeo de {{name}} ya no está fijado.",
"self": {
"pin": "Tu vídeo está fijado.",
"unpin": "Tu vídeo ya no está fijado."
}
},
"recordingStateToast": {
"transcript": {
"started": "Transcripción en curso",
"starting": "Iniciando la transcripción",
"stopping": "Deteniendo la transcripción"
},
"screen_recording": {
"started": "Grabación en curso",
"starting": "Iniciando la grabación",
"stopping": "Deteniendo la grabación"
},
"any": {
"started": "Grabación en curso"
},
"limitReachedAlert": {
"title": "Límite de grabación superado",
"description": "La grabación ha superado la duración máxima permitida{{duration_message}}. Ahora se va a guardar automáticamente.",
"durationMessage": " de {{duration}}",
"button": "Aceptar"
}
},
"participantTileFocus": {
"containerLabel": "Opciones para {{name}}",
"toolbarHint": "{{shortcut}}: acceder directamente a los atajos.",
"pin": {
"enable": "Fijar",
"disable": "Cancelar la fijación"
},
"effects": "Fondos y efectos",
"muteParticipant": "Silenciar el micrófono de {{name}}",
"fullScreen": "Pantalla completa"
},
"shortcutsPanel": {
"title": "Atajos de teclado",
"categories": {
"navigation": "Navegación",
"media": "Multimedia",
"interaction": "Interacción"
},
"actions": {
"open-shortcuts": "Abrir la ayuda de atajos",
"focus-toolbar": "Poner el foco en la barra de herramientas inferior",
"toggle-microphone": "Activar o desactivar el micrófono",
"toggle-camera": "Activar o desactivar la cámara",
"push-to-talk": "Pulsar para hablar (mantener para reactivar)",
"reaction": "Panel de reacciones",
"fullscreen": "Cambiar a pantalla completa",
"recording": "Mostrar u ocultar el panel de grabación",
"raise-hand": "Levantar o bajar la mano",
"toggle-chat": "Mostrar/Ocultar el chat",
"toggle-participants": "Mostrar/Ocultar los participantes",
"open-shortcuts-settings": "Abrir los ajustes de los atajos"
},
"sr": {
"control": "Control",
"command": "Comando",
"alt": "Alt",
"option": "Opción",
"shift": "Mayúsculas",
"plus": "más",
"hold": "Mantener {{key}}",
"noShortcut": "Ningún atajo"
},
"visual": {
"hold": "Mantener {{key}}"
}
},
"fullScreenWarning": {
"message": "Para evitar el efecto de bucle infinito, no compartas toda tu pantalla. Comparte mejor una pestaña u otra ventana.",
"stop": "Detener la presentación",
"ignore": "Ignorar"
},
"participantTile": {
"screenShare": "Pantalla de {{name}}"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"createMeeting": {
"createButton": "Crear un enlace de Visio",
"joinButton": "Unirse con Visio",
"copyLinkTooltip": "Copiar el enlace",
"resetLabel": "Restablecer",
"participantLimit": "Hasta 150 participantes.",
"popupBlocked": "Se ha bloqueado la ventana emergente. Permite las ventanas emergentes para este sitio.",
"settingsTooltip": "Configuración de la reunión"
},
"roomSettings": {
"title": "Configuración de la reunión",
"description": "Configura la reunión {{roomSlug}}.",
"notAllowed": "No tienes permisos para modificar la configuración de esta reunión.",
"error": "No se ha podido cargar la configuración de la reunión.",
"closeButton": "Cerrar"
}
}
+189
View File
@@ -0,0 +1,189 @@
{
"account": {
"currentlyLoggedAs": "Actualmente has iniciado sesión como <0>{{user}}</0>",
"heading": "Cuenta",
"youAreNotLoggedIn": "No has iniciado sesión.",
"nameLabel": "Tu nombre",
"authentication": "Autenticación",
"nameError": "Tu nombre no puede estar vacío"
},
"preferences": {
"title": "Preferencias",
"idleDisconnectModal": {
"label": "Salir de las llamadas sin ningún otro participante",
"description": "Te saca de una llamada al cabo de unos minutos si ningún otro participante se une"
},
"autoMuteLargeRoom": {
"label": "Silenciar automáticamente mi micrófono",
"description": "Silencia automáticamente tu micrófono cuando te unes a una reunión numerosa."
}
},
"audio": {
"microphone": {
"heading": "Micrófono",
"label": "Dispositivo de entrada de audio",
"disabled": "Micrófono desactivado"
},
"noiseReduction": {
"label": "Reducción de ruido",
"heading": "Reducción de ruido",
"ariaLabel": {
"enable": "Activar la reducción de ruido",
"disable": "Desactivar la reducción de ruido"
}
},
"speakers": {
"heading": "Altavoces",
"label": "Dispositivo de salida de audio",
"test": "Probar",
"ongoingTest": "Probando el sonido…",
"safariWarning": "La selección del altavoz todavía no está disponible en Safari debido a limitaciones del navegador."
},
"permissionsRequired": "Permisos necesarios"
},
"video": {
"camera": {
"heading": "Cámara",
"label": "Dispositivo de entrada de vídeo",
"disabled": "Cámara desactivada",
"previewAriaLabel": {
"enabled": "Vista previa de vídeo activada",
"disabled": "Vista previa de vídeo desactivada"
}
},
"resolution": {
"heading": "Resolución",
"publish": {
"label": "Selecciona tu resolución de envío (máx.)",
"items": {
"high": "Alta definición",
"medium": "Definición estándar",
"low": "Baja definición"
}
},
"subscribe": {
"label": "Selecciona tu resolución de recepción (máx.)",
"items": {
"high": "Alta definición (automática)",
"medium": "Definición estándar",
"low": "Baja definición"
}
}
},
"permissionsRequired": "Permisos necesarios"
},
"transcription": {
"heading": "Transcripción",
"language": {
"label": "Idioma de la reunión",
"options": {
"french": "Francés (fr)",
"english": "Inglés (en)",
"dutch": "Neerlandés (nl)",
"german": "Alemán (de)",
"auto": "Automático"
}
}
},
"notifications": {
"heading": "Notificaciones sonoras",
"label": "la notificación sonora para",
"actions": {
"disable": "Desactivar",
"enable": "Activar"
},
"items": {
"participantJoined": {
"label": "Un nuevo participante"
},
"handRaised": {
"label": "Una mano levantada"
},
"messageReceived": {
"label": "Un mensaje recibido"
}
}
},
"shortcuts": {
"listLabel": "Lista de atajos de teclado",
"columnAction": "Acción",
"columnShortcut": "Atajo"
},
"dialog": {
"heading": "Configuración"
},
"language": {
"heading": "Idioma",
"label": "Idioma de la aplicación"
},
"settingsButtonLabel": "Configuración",
"accessibility": {
"announceReactions": {
"label": "Vocalizar las reacciones"
},
"font": {
"label": "Fuente de la interfaz",
"description": "Personaliza la fuente utilizada en la interfaz para mejorar tu comodidad de lectura.",
"options": {
"default": "Predeterminada",
"lexend": "Lexend (legibilidad mejorada)",
"atkinson-hyperlegible": "Atkinson Hyperlegible (baja visión)",
"opendyslexic": "OpenDyslexic (dislexia)"
}
},
"captions": {
"heading": "Subtítulos",
"textSize": {
"label": "Tamaño del texto de los subtítulos",
"options": {
"small": "Pequeño",
"medium": "Mediano",
"large": "Grande"
}
},
"fontColor": {
"label": "Color del texto",
"options": {
"default": "Predeterminado",
"white": "Blanco",
"black": "Negro",
"blue": "Azul",
"green": "Verde",
"red": "Rojo",
"yellow": "Amarillo",
"cyan": "Cian",
"magenta": "Magenta"
}
},
"backgroundColor": {
"label": "Color de fondo",
"options": {
"default": "Predeterminado",
"white": "Blanco",
"black": "Negro",
"blue": "Azul",
"green": "Verde",
"red": "Rojo",
"yellow": "Amarillo",
"cyan": "Cian",
"magenta": "Magenta"
}
}
}
},
"tabs": {
"account": "Perfil",
"audio": "Audio",
"video": "Vídeo",
"general": "General",
"notifications": "Notificaciones",
"accessibility": "Accesibilidad",
"transcription": "Transcripción",
"shortcuts": "Atajos",
"rooms": "Reuniones"
},
"roomDefaults": {
"heading": "Ajustes predeterminados de las reuniones",
"description": "Elige la configuración que se aplica de forma predeterminada a las nuevas reuniones que crees. Siempre podrás modificarla para cada reunión desde el menu de configuración."
}
}
@@ -0,0 +1,130 @@
{
"title": "Condiciones de uso de Visio",
"articles": {
"article1": {
"title": "1. Ámbito de aplicación",
"content": "Este documento define las condiciones de uso de Visio (en adelante, «Visio» o «la aplicación»), una aplicación de videoconferencia diseñada especialmente para las administraciones del Estado y los organismos bajo su tutela."
},
"article2": {
"title": "2. Objeto de la aplicación",
"content": "Visio permite favorecer la cooperación de los equipos de las administraciones del Estado y de los organismos bajo su tutela, así como el trabajo a distancia, y facilitar la organización de reuniones, conferencias o formaciones.",
"paragraphs": [
"Visio está desarrollada y operada por la Dirección Interministerial de lo Digital (DINUM).",
"La DINUM pone la aplicación a disposición de las administraciones del Estado y de los organismos bajo su tutela, destinada a sus empleados, colaboradores y proveedores invitados.",
"Todo uso de Visio debe respetar estas condiciones de uso y, en su caso, la doctrina de empleo de la administración de adscripción."
]
},
"article3": {
"title": "3. Definiciones",
"paragraphs": [
"Visio es el producto cuyo uso se describe en estas condiciones y que se presenta en la siguiente página: https://visio.numerique.gouv.fr.",
"Los usuarios y usuarias de Visio son toda persona que utilice Visio con fines de comunicación por videoconferencia, ya sean empleados, colaboradores o proveedores de las administraciones de adscripción. La administración de adscripción es la administración del Estado y los organismos bajo su tutela que pone Visio a disposición de sus empleados, colaboradores o proveedores.",
"Los organizadores y organizadoras de sala son usuarios y usuarias responsables de una sala de videoconferencia tras haber iniciado sesión mediante ProConnect.",
"La administración de adscripción es la administración perteneciente al sistema de información y comunicación del Estado que pone Visio a disposición de sus empleados, colaboradores o proveedores."
]
},
"article4": {
"title": "4. Acceso al servicio",
"definition": "Visio se pone a disposición de los usuarios y usuarias por parte de su administración de adscripción. Los organizadores y organizadoras pueden después transmitir un enlace que permite acceder a la sala a cualquier persona, bajo su responsabilidad."
},
"article5": {
"title": "5. Funcionalidades",
"sections": {
"section1": {
"title": "5.1. Funcionalidades abiertas a los organizadores y organizadoras",
"content": "Visio ofrece a los empleados de las administraciones del Estado y de los organismos bajo su tutela la posibilidad de crear una sala de videoconferencia desde un puesto de trabajo estándar, vinculado a su cuenta ProConnect.",
"paragraph1": "A continuación puede compartir el enlace de la reunión con otras personas, usuarios y usuarias, formen parte o no de la administración.",
"paragraph2": "A tal efecto, cada organizador u organizadora de sala puede:",
"capabilities": [
"Proteger el acceso a la sala mediante una sala de espera;",
"Autorizar la entrada de cada usuario y usuaria en la sala;",
"Silenciar los micrófonos y las cámaras de los usuarios y usuarias;",
"Expulsar a un usuario o usuaria de la sala;",
"Utilizar la herramienta de transcripción de la reunión tras haber obtenido la conformidad de los usuarios y usuarias, bajo su responsabilidad."
],
"paragraph3": "Los empleados deben dirigirse a su administración de adscripción para asegurarse del marco de uso dentro de Visio, en particular en lo que respecta a las reuniones sujetas a obligaciones reforzadas de confidencialidad.",
"paragraph4": "Los organizadores y organizadoras pueden utilizar la herramienta de transcripción automática desde el menú de configuración de la sala. Corresponde a los organizadores y organizadoras asegurarse de la conformidad de los usuarios y usuarias que participan en la reunión transcrita. El idioma de transcripción puede modificarse desde el mismo menú. Al final de la reunión, se transmite al organizador u organizadora un enlace para acceder a la transcripción conservada en la herramienta denominada Docs, operada por la DINUM."
},
"section2": {
"title": "5.2. Funcionalidades abiertas a los usuarios y usuarias",
"content": "Visio permite a todo usuario y usuaria, sea o no empleado público, participar en cualquier sala de Visio a la que le invite un organizador u organizadora y disfrutar de las funcionalidades.",
"paragraph": "A tal efecto, cada usuario y usuaria puede:",
"capabilities": [
"facilitar una identidad mostrada al entrar en la sala;",
"comunicarse a distancia por audio o vídeo;",
"compartir su pantalla;",
"intercambiar mensajes durante la sesión de forma pública con todos los usuarios y usuarias de la misma;",
"acceder a información técnica sobre la sala y a funcionalidades de accesibilidad;",
"activar o desactivar su cámara o su micrófono en cualquier momento;",
"proteger la intimidad de su entorno en caso de activar la cámara, seleccionando y activando un fondo de pantalla antes de entrar en la sala o incluso durante la videoconferencia."
]
}
}
},
"article6": {
"title": "6. Compromisos y responsabilidades de los usuarios y usuarias",
"sections": {
"section1": {
"title": "6.1 Usos conformes",
"paragraphs": [
"Visio se pone a disposición para facilitar los intercambios profesionales entre los empleados de las administraciones del Estado y sus organismos bajo tutela y los colaboradores externos a la administración.",
"El usuario o la usuaria es responsable de las imágenes y los sonidos que hace accesibles en la sala de videoconferencia. Esta responsabilidad se aplica tanto a lo que la cámara o el micrófono pueden difundir como a lo que su terminal puede hacer accesible a terceros. A tal fin, el usuario o la usuaria procura utilizar la aplicación en un entorno profesional y no difundir a terceros ninguna información cubierta por la obligación de discreción profesional (por ejemplo: uso de filtro de privacidad, de auriculares, etc.).",
"El usuario o la usuaria es responsable de los datos o contenidos que introduce en el servicio de mensajería de la sala de videoconferencia. Procura introducir únicamente mensajes apropiados."
]
},
"section2": {
"title": "6.2 Usos prohibidos",
"paragraphs": [
"El usuario o la usuaria se compromete a no introducir en el servicio de mensajería de la sala de Visio ni hacer accesibles mediante imagen o sonido contenidos o información contrarios a las disposiciones legales y reglamentarias vigentes."
]
}
}
},
"article7": {
"title": "7. Compromisos y responsabilidades de la DINUM",
"sections": {
"section1": {
"title": "7.1. Seguridad de la aplicación",
"paragraphs": [
"La DINUM se compromete a la protección de Visio, en particular adoptando todas las medidas necesarias para garantizar la seguridad y la confidencialidad de los intercambios.",
"La aplicación está homologada en las condiciones previstas por el decreto n.º 2010-112 modificado."
]
},
"section2": {
"title": "7.2. Acceso a la aplicación",
"paragraphs": [
"La DINUM se compromete a proporcionar los medios necesarios y razonables para garantizar un acceso continuo a la aplicación.",
"El horario de apertura de la aplicación es 24 horas al día, 7 días a la semana, salvo en los periodos de indisponibilidad por mantenimiento.",
"La DINUM persigue un objetivo de disponibilidad anual de la aplicación del 99,5 %, sin contar las indisponibilidades planificadas. En caso de incidencia o mantenimiento, aspira a un plazo de restablecimiento de 72 horas en horas no laborables.",
"En el marco de un mantenimiento del entorno de ejecución de la aplicación, la DINUM se reserva el derecho de suspender temporalmente el funcionamiento de la aplicación. En la medida de lo posible, informa a los usuarios con un mínimo de 48 horas de antelación. En caso de urgencia, esta suspensión puede producirse sin previo aviso.",
"Estas interrupciones excepcionales pueden ser necesarias, por ejemplo, para operaciones de gestión de datos, de puesta en producción o de cambios de arquitectura.",
"La indisponibilidad de la aplicación no da derecho a compensación alguna de ninguna naturaleza."
]
},
"section3": {
"title": "7.3. Gestión del soporte",
"paragraphs": [
"La DINUM presta el soporte de primer nivel a los usuarios y usuarias, exclusivamente sobre los aspectos técnicos de la aplicación.",
"Se puede contactar con este soporte mediante el formulario de contacto en línea.",
"El objetivo de respuesta a las solicitudes es de dos días laborales desde el envío del correo electrónico; este plazo puede variar según la complejidad y el número de solicitudes que deban tramitarse."
]
},
"section4": {
"title": "7.4. Control del uso de la aplicación",
"paragraphs": [
"La DINUM se reserva el derecho de suspender o eliminar la cuenta de un usuario o usuaria que haya incumplido estas condiciones de uso, sin perjuicio de las eventuales acciones de responsabilidad penal y civil que pudieran emprenderse contra la persona afectada. Los elementos podrían transmitirse a la administración de adscripción del usuario o la usuaria, al ser esta responsable de la puesta a disposición de la aplicación.",
"La suspensión o revocación de una o varias cuentas no da lugar a compensación alguna de ningún tipo."
]
},
"section5": {
"title": "7.5. Código abierto y licencias",
"content": "El código fuente del servicio es libre y está disponible aquí: https://github.com/suitenumerique/meet. Los contenidos propuestos por la DINUM están bajo Licencia Abierta 2.0, a excepción de los logotipos y de las representaciones iconográficas y fotográficas, que pueden regirse por sus propias licencias."
}
}
},
"article8": {
"title": "8. Evolución de las condiciones de uso",
"content": "Los términos de estas condiciones de uso pueden modificarse o completarse en cualquier momento, sin previo aviso, en función de las modificaciones introducidas en el servicio, de la evolución de la legislación o por cualquier otro motivo que se considere necesario.\nEstas modificaciones y actualizaciones se imponen al usuario o la usuaria, que debe, en consecuencia, consultar regularmente esta sección para comprobar las condiciones vigentes."
}
}
}
+2 -1
View File
@@ -435,7 +435,8 @@
"tools": "outils de réunion",
"info": "informations sur la réunion"
},
"closeButton": "Masquer {{content}}"
"closeButton": "Masquer {{content}} (Échap)",
"escapeHint": "Appuyez sur Échap pour fermer"
},
"chat": {
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel.",
+2 -1
View File
@@ -435,7 +435,8 @@
"tools": "meer tools",
"info": "vergaderinformatie"
},
"closeButton": "Verberg {{content}}"
"closeButton": "Verberg {{content}} (Escape)",
"escapeHint": "Druk op Escape om te sluiten"
},
"chat": {
"disclaimer": "De berichten zijn alleen voor de deelnemers zichtbaar op het moment dat ze worden verzonden. Alle berichten worden verwijderd aan het einde van het gesprek.",
+3 -2
View File
@@ -1,13 +1,14 @@
// Map frontend language codes to backend language codes
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl' | 'de-de'
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de'
export type BackendLanguage = 'en-us' | 'fr-fr' | 'nl-nl' | 'de-de' | 'es-es'
export type FrontendLanguage = 'en' | 'fr' | 'nl' | 'de' | 'es'
const frontendToBackendMap: Record<FrontendLanguage, BackendLanguage> = {
en: 'en-us',
fr: 'fr-fr',
nl: 'nl-nl',
de: 'de-de',
es: 'es-es',
}
export const convertToBackendLanguage = (
+1
View File
@@ -2,6 +2,7 @@
[project]
name = "summary"
version = "1.29.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
+1891
View File
File diff suppressed because it is too large Load Diff