Compare commits

..

3 Commits

Author SHA1 Message Date
lebaudantoine 3d7f2957f9 wip support experimental calendar api 2026-05-01 18:43:37 +02:00
lebaudantoine d83d3ccc18 insert link in mail 2026-05-01 17:43:18 +02:00
lebaudantoine 52a9a583e8 wip initiate dev environment 2026-05-01 17:12:53 +02:00
160 changed files with 6394 additions and 10633 deletions
+6 -11
View File
@@ -150,14 +150,13 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: uv run ruff format . --diff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: uv run ruff check .
run: ~/.local/bin/ruff check .
lint-summary:
runs-on: ubuntu-latest
@@ -305,6 +304,7 @@ jobs:
working-directory: src/summary
env:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
@@ -322,11 +322,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Install Python
uses: actions/setup-python@v6
with:
-41
View File
@@ -8,46 +8,6 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(fullstack) allow participants to mute others based on room configuration
- ✨(frontend) add synchronizer for room metadata updates
- ✨(frontend) make reaction toolbar responsive on small viewports
- ✨(frontend) enable reactions on mobile devices
- ✨(frontend) introduce picture-in-picture meeting
### Changed
- ♻️(fullstack) simplify source serialization
- ✨(backend) expose room configuration to all API consumers
- 🩹(frontend) improve reaction toolbar centering with dynamic positioning
## [1.16.0] - 2026-05-13
### Added
- 🔒️(backend) add validation of Room.configuration
- ✨(helm) add support multiple transcribe worker / endpoint #1247
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
- ✨(summary) add speaker-to-participant assignment
### Changed
- ♻️(summary) change tasks endpoint signature
- ⬆️(dependencies) update urllib3 to v2.7.0 [SECURITY]
- 🧑‍💻(agents) use `uv` for package management
- ✨(summary) improve speaker-to-participant assignment
### Fixed
- ♻(frontend) standardize role terminology across localizations
- 🐛(backend) make start-recording atomic and fault-tolerant
- 🔒️(frontend) room ids are generated with non-cryptographic rand
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
- 🐛(summary) complete webm support #1328
- 🐛(backend) add link to "Open" text in recording email
- 🩹(frontend) fix spacing regression in mobile control bar
## [1.15.0] - 2026-04-30
### Added
@@ -85,7 +45,6 @@ and this project adheres to
- ✨(summary) allow more file extensions #1265
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
- ♿️(frontend) set an explicit document title on recording download page #1261
- ♿️(frontend) add customizable accessibility fonts #1270
### Fixed
+2 -2
View File
@@ -109,7 +109,7 @@ build-frontend: ## build the frontend container
.PHONY: build-frontend
build-agents: ## build the multi-user-transcriber agent container
@$(COMPOSE) build multi-user-transcriber-dev
@$(COMPOSE) build multi-user-transcriber
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes
@@ -138,7 +138,7 @@ run-agents: ## start the multi-user-transcriber agent
.PHONY: run-agents
run-agent-multi-user-transcriber: ## start the LiveKit agents (multi users transcriber)
@$(COMPOSE) up --force-recreate -d multi-user-transcriber-dev
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
.PHONY: run-agent-multi-user-transcriber
run-agent-metadata-collector: ## start the LiveKit agents (metadata collector)
+1 -1
View File
@@ -109,7 +109,7 @@ k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'liv
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
-7
View File
@@ -101,12 +101,6 @@ update_npm_version "mail"
# Update backend pyproject.toml
update_python_version "backend"
# Run uv lock in backend
print_info "Running uv lock in backend..."
cd "src/backend"
uv lock
cd -
# Update summary pyproject.toml
update_python_version "summary"
@@ -155,7 +149,6 @@ echo " - src/frontend/package.json"
echo " - src/sdk/package.json"
echo " - src/mail/package.json"
echo " - src/backend/pyproject.toml"
echo " - src/backend/uv.lock"
echo " - src/summary/pyproject.toml"
echo " - src/agents/pyproject.toml"
echo " - CHANGELOG.md"
+8 -12
View File
@@ -249,7 +249,6 @@ services:
metadata-collector-dev:
build:
context: ./src/agents
target: development
command: ["python", "metadata_collector.py", "dev"]
environment:
- LIVEKIT_URL=ws://livekit:7880
@@ -262,7 +261,6 @@ services:
- AWS_S3_SECURE_ACCESS=False
volumes:
- ./src/agents:/app
- /app/.venv
depends_on:
- livekit
- minio
@@ -271,16 +269,6 @@ services:
- action: rebuild
path: ./src/agents
multi-user-transcriber-dev:
build:
context: ./src/agents
target: development
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
- /app/.venv
redis-summary:
image: redis
ports:
@@ -342,6 +330,14 @@ services:
- action: rebuild
path: ./src/summary
multi-user-transcriber:
build:
context: ./src/agents
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
networks:
default:
resource-server:
-58
View File
@@ -100,13 +100,6 @@ sequenceDiagram
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
### Manual Storage Webhook
@@ -148,54 +141,3 @@ Using default project meet
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
## Tuning recording encoding
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
### How values map to GStreamer
| Setting | GStreamer element | Property |
| ------------------------------------- | ----------------- | ---------------------------------- |
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
### Reference profiles
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
★ Recommended starting point for typical LaSuite Meet usage.
Environment variables for the **Low CPU / small file** profile:
```bash
RECORDING_ENCODING_ENABLED=True
RECORDING_ENCODING_WIDTH=1280
RECORDING_ENCODING_HEIGHT=720
RECORDING_ENCODING_FRAMERATE=15
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
```
### Caveats
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
+1 -14
View File
@@ -64,23 +64,10 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
# file size and CPU load on the egress worker.
# RECORDING_ENCODING_ENABLED=False
# RECORDING_ENCODING_WIDTH=1280
# RECORDING_ENCODING_HEIGHT=720
# RECORDING_ENCODING_FRAMERATE=30
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Telephony
ROOM_TELEPHONY_ENABLED=True
+287 -307
View File
@@ -19,7 +19,7 @@
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
@@ -36,7 +36,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4"
"webpack-dev-server": "5.1.0"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
@@ -4319,195 +4319,44 @@
"node": ">= 4.0.0"
}
},
"node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
"node": ">= 8"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz",
"integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==",
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz",
"integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz",
"integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-rsa": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz",
"integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz",
"integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.7.0",
"@peculiar/asn1-pfx": "^2.7.0",
"@peculiar/asn1-pkcs8": "^2.7.0",
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"@peculiar/asn1-x509-attr": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz",
"integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz",
"integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">=20.0.0"
"node": ">= 8"
}
},
"node_modules/@peculiar/x509/node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
@@ -4521,6 +4370,19 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@sindresorhus/merge-streams": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4722,6 +4584,16 @@
"form-data": "^4.0.4"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/office-js": {
"version": "1.0.582",
"resolved": "https://registry.npmjs.org/@types/office-js/-/office-js-1.0.582.tgz",
@@ -5778,21 +5650,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
@@ -6264,16 +6121,6 @@
"node": ">= 0.8"
}
},
"node_modules/bytestreamjs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -6839,20 +6686,21 @@
"license": "MIT"
},
"node_modules/copy-webpack-plugin": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz",
"integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
"integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.1",
"globby": "^14.0.0",
"normalize-path": "^3.0.0",
"schema-utils": "^4.2.0",
"serialize-javascript": "^7.0.3",
"tinyglobby": "^0.2.12"
"serialize-javascript": "^6.0.2"
},
"engines": {
"node": ">= 20.9.0"
"node": ">= 18.12.0"
},
"funding": {
"type": "opencollective",
@@ -8214,6 +8062,36 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8302,6 +8180,16 @@
"node": ">= 4.9.1"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/faye-websocket": {
"version": "0.11.4",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
@@ -8870,6 +8758,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globby": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^2.1.0",
"fast-glob": "^3.3.3",
"ignore": "^7.0.3",
"path-type": "^6.0.0",
"slash": "^5.1.0",
"unicorn-magic": "^0.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -9077,6 +8996,23 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -11125,6 +11061,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@@ -12709,6 +12655,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-type": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pathval": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
@@ -12844,24 +12803,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pkijs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@noble/hashes": "1.4.0",
"asn1js": "^3.0.6",
"bytestreamjs": "^2.0.1",
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -13045,26 +12986,6 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
@@ -13081,6 +13002,37 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -13567,6 +13519,17 @@
"node": ">= 4"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
@@ -13599,6 +13562,30 @@
"node": ">=0.12.0"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
@@ -13793,17 +13780,17 @@
"license": "MIT"
},
"node_modules/selfsigned": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz",
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==",
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz",
"integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@peculiar/x509": "^1.14.2",
"pkijs": "^3.3.3"
"@types/node-forge": "^1.3.0",
"node-forge": "^1"
},
"engines": {
"node": ">=18"
"node": ">=10"
}
},
"node_modules/semver": {
@@ -13872,13 +13859,13 @@
}
},
"node_modules/serialize-javascript": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=20.0.0"
"dependencies": {
"randombytes": "^2.1.0"
}
},
"node_modules/serve-index": {
@@ -14290,6 +14277,19 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/sockjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -15037,26 +15037,6 @@
"dev": true,
"license": "0BSD"
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -15348,6 +15328,19 @@
"node": ">=4"
}
},
"node_modules/unicorn-magic": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -15678,16 +15671,15 @@
}
},
"node_modules/webpack-dev-server": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.1.0.tgz",
"integrity": "sha512-aQpaN81X6tXie1FoOB7xlMfCsN19pSvRAeYUHOdFWOlhpQ/LlbfTqYwwmEDFV0h8GGuqmCmKmT+pxcUV/Nt2gQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
"@types/express": "^4.17.25",
"@types/express-serve-static-core": "^4.17.21",
"@types/express": "^4.17.21",
"@types/serve-index": "^1.9.4",
"@types/serve-static": "^1.15.5",
"@types/sockjs": "^0.3.36",
@@ -15696,17 +15688,18 @@
"bonjour-service": "^1.2.1",
"chokidar": "^3.6.0",
"colorette": "^2.0.10",
"compression": "^1.8.1",
"compression": "^1.7.4",
"connect-history-api-fallback": "^2.0.0",
"express": "^4.22.1",
"express": "^4.19.2",
"graceful-fs": "^4.2.6",
"http-proxy-middleware": "^2.0.9",
"html-entities": "^2.4.0",
"http-proxy-middleware": "^2.0.3",
"ipaddr.js": "^2.1.0",
"launch-editor": "^2.6.1",
"open": "^10.0.3",
"p-retry": "^6.2.0",
"schema-utils": "^4.2.0",
"selfsigned": "^5.5.0",
"selfsigned": "^2.4.1",
"serve-index": "^1.9.1",
"sockjs": "^0.3.24",
"spdy": "^4.0.2",
@@ -15735,19 +15728,6 @@
}
}
},
"node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": {
"version": "4.19.8",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/webpack-dev-server/node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
@@ -15762,9 +15742,9 @@
}
},
"node_modules/webpack-dev-server/node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15791,9 +15771,9 @@
}
},
"node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"dev": true,
"license": "MIT",
"engines": {
+2 -2
View File
@@ -36,7 +36,7 @@
"@types/office-runtime": "^1.0.35",
"acorn": "^8.11.3",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^14.0.0",
"copy-webpack-plugin": "^12.0.2",
"eslint-plugin-office-addins": "^4.0.3",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
@@ -53,7 +53,7 @@
"source-map-loader": "^5.0.0",
"webpack": "^5.95.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "5.2.4"
"webpack-dev-server": "5.1.0"
},
"prettier": "office-addin-prettier-config",
"browserslist": [
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "thunderbird",
"version": "1.0.0",
"main": "index.js",
"private": true,
"scripts": {
"dev": "web-ext run",
"lint": "web-ext lint --source-dir=./src"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"web-ext": "^10.1.0"
}
}
+48
View File
@@ -0,0 +1,48 @@
import { buildMeetingMessage } from "./lib/meeting-message.js";
console.log("[meeting-link] background loaded at", new Date().toISOString());
(async () => {
try {
console.log("[meeting-link] browser.calendar exists?", !!browser.calendar);
if (browser.calendar) {
console.log("[meeting-link] calendar namespace keys:",
Object.keys(browser.calendar));
}
// Try the most basic read call — list configured calendars.
// The exact method name varies between experiment versions; we'll
// try the common one first.
if (browser.calendar.calendars?.query) {
const calendars = await browser.calendar.calendars.query({});
console.log("[meeting-link] calendars found:", calendars.length, calendars);
} else {
console.warn("[meeting-link] calendars.query not present — namespace shape:",
browser.calendar);
}
} catch (err) {
console.error("[meeting-link] calendar probe failed:", err);
}
})();
// Hardcoded for Spike 1. Spike 2 replaces this with a fetch() to your API.
const STUB_MEETING_DATA = {
url: "https://meet.example.com/m/abc-123-xyz",
telephony: {
phone_number: "+33123456789",
pin_code: "1234567890",
},
};
browser.runtime.onMessage.addListener(async (msg) => {
if (msg?.type === "GET_MEETING_MESSAGE") {
try {
const built = buildMeetingMessage(STUB_MEETING_DATA);
return { ok: true, ...built };
} catch (err) {
console.error("[meeting-link] build failed", err);
return { ok: false, error: String(err.message || err) };
}
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

+41
View File
@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "Meeting Link (dev)",
"version": "0.1.0",
"description": "Spike 0 — verifying the dev loop",
"browser_specific_settings": {
"gecko": {
"id": "meeting-link-dev@yourcompany.example",
"strict_min_version": "128.0"
}
},
"background": {
"scripts": ["background.js"],
"type": "module"
},
"compose_action": {
"default_title": "Insert meeting link",
"default_popup": "popup/popup.html",
"default_icon": "icons/icon-32.png"
},
"permissions": ["compose"],
"experiment_apis": {
"calendar_provider": {
"schema": "experiments/calendar/schema/calendar-provider.json",
"parent": {
"scopes": ["addon_parent"],
"paths": [["calendar", "provider"]],
"script": "experiments/calendar/parent/ext-calendar-provider.js",
"events": ["startup"]
}
},
"calendar_calendars": {
"schema": "experiments/calendar/schema/calendar-calendars.json",
"parent": {
"scopes": ["addon_parent"],
"paths": [["calendar", "calendars"]],
"script": "experiments/calendar/parent/ext-calendar-calendars.js"
}
}
}
}
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Meeting link</title>
<style>
body { font: 13px system-ui; padding: 12px; min-width: 240px; margin: 0; }
button { font: inherit; padding: 6px 12px; cursor: pointer; }
#status { margin-top: 8px; color: #555; min-height: 1.2em; white-space: pre-wrap; }
#status.error { color: #b00020; }
</style>
</head>
<body>
<button id="insert">Insert meeting link</button>
<div id="status"></div>
<script src="popup.js"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
const insertBtn = document.getElementById("insert");
const statusEl = document.getElementById("status");
function setStatus(text, isError = false) {
statusEl.textContent = text;
statusEl.classList.toggle("error", isError);
}
insertBtn.addEventListener("click", async () => {
insertBtn.disabled = true;
setStatus("Generating link…");
try {
// 1. Find the compose tab this popup belongs to.
// A compose_action popup is anchored to a compose window, so the
// "active tab in the current window" is the compose tab itself.
const [composeTab] = await browser.tabs.query({
active: true,
currentWindow: true,
});
if (!composeTab) throw new Error("No compose tab found");
// 2. Read the current compose state — we need to know if we're
// in HTML mode or plain-text mode, and we need the existing body
// so we can append rather than overwrite.
const details = await browser.compose.getComposeDetails(composeTab.id);
// 3. Ask the background to produce the meeting message.
const reply = await browser.runtime.sendMessage({
type: "GET_MEETING_MESSAGE",
});
if (!reply?.ok) throw new Error(reply?.error || "Background error");
// 4. Append to the existing body in the right format.
if (details.isPlainText) {
const newBody = (details.plainTextBody || "") + "\n\n" + reply.text;
await browser.compose.setComposeDetails(composeTab.id, {
plainTextBody: newBody,
});
} else {
const newBody = appendHtmlBeforeBodyEnd(details.body || "", reply.html);
await browser.compose.setComposeDetails(composeTab.id, {
body: newBody,
});
}
setStatus("Inserted ✓");
setTimeout(() => window.close(), 600);
} catch (err) {
console.error("[meeting-link] popup insert failed", err);
setStatus("Failed: " + (err.message || err), true);
insertBtn.disabled = false;
}
});
/**
* Append HTML right before </body>, or fall back to concatenation if no
* </body> tag is present (Thunderbird's compose body is usually a full
* HTML document, but be defensive).
*/
function appendHtmlBeforeBodyEnd(currentHtml, fragment) {
const idx = currentHtml.toLowerCase().lastIndexOf("</body>");
if (idx === -1) return currentHtml + fragment;
return currentHtml.slice(0, idx) + fragment + currentHtml.slice(idx);
}
+30
View File
@@ -0,0 +1,30 @@
// web-ext-config.cjs
const os = require("os");
const path = require("path");
function thunderbirdBinary() {
if (process.env.WEB_EXT_FIREFOX) return process.env.WEB_EXT_FIREFOX;
switch (process.platform) {
case "darwin":
return "/Applications/Thunderbird Beta.app/Contents/MacOS/thunderbird";
case "linux":
return "/usr/bin/thunderbird"; // adjust to your install
case "win32":
return "C:\\Program Files\\Mozilla Thunderbird\\thunderbird.exe";
default:
throw new Error("Unsupported platform: " + process.platform);
}
}
module.exports = {
sourceDir: "./src",
artifactsDir: "./web-ext-artifacts",
run: {
firefox: thunderbirdBinary(),
firefoxProfile: path.join(os.homedir(), ".thunderbird-dev-profile"),
profileCreateIfMissing: true,
keepProfileChanges: true,
browserConsole: true,
},
ignoreFiles: ["package-lock.json", "web-ext-config.cjs", "*.md"],
};
-38
View File
@@ -1,38 +0,0 @@
# Python
__pycache__
*.pyc
**/__pycache__
**/*.pyc
venv
**/.venv
# System-specific files
.DS_Store
**/.DS_Store
# Docker
compose.*
env.d
# Docs
docs
*.md
*.log
# Development/test cache & configurations
data
.cache
.circleci
.git
.iml
db.sqlite3
.pylint.d
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Env
.env
+11 -39
View File
@@ -6,61 +6,31 @@ RUN apt-get update && apt-get install -y \
libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/*
# ---- Builder image ----
FROM base AS builder
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
WORKDIR /builder
# Install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
COPY pyproject.toml .
WORKDIR /app
RUN mkdir /install && \
pip install --prefix=/install .
# Install production dependencies without the project itself (cacheable layer)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
# Install the project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- Development image ----
FROM base AS development
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=0
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
COPY . /app
COPY pyproject.toml .
RUN pip install --no-cache-dir ".[dev]"
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --all-extras
COPY . .
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "metadata_collector.py", "dev"]
CMD ["python", "multi_user_transcriber.py", "dev"]
# ---- Production image ----
FROM base AS production
WORKDIR /app
# Copy the pre-built virtualenv and application source
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
COPY --from=builder /install /usr/local
# Remove pip to reduce attack surface in production
RUN pip uninstall -y pip
@@ -69,4 +39,6 @@ RUN pip uninstall -y pip
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY ./*.py /app/
CMD ["python", "multi_user_transcriber.py", "start"]
+2 -2
View File
@@ -177,7 +177,7 @@ class MetadataCollector:
def save(self):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata...")
logger.info("Persisting metadata")
participants = []
for k, v in self.participants.items():
@@ -372,7 +372,7 @@ async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector...")
logger.info("Shutting down metadata collector")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
+7 -3
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.16.0"
version = "1.15.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.4.5",
@@ -18,8 +18,12 @@ dev = [
"ruff==0.15.6",
]
[tool.uv]
package = false
[tool.setuptools]
py-modules = ["multi_user_transcriber", "metadata_collector", "exceptions"]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py313"
-1963
View File
File diff suppressed because it is too large Load Diff
-30
View File
@@ -136,33 +136,3 @@ class FilePermission(IsAuthenticated):
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
class CanMuteParticipant(permissions.BasePermission):
"""
Grant muting rights based on role or room configuration.
- Admins and owners can always mute.
- When `everyone_can_mute` is enabled on the room, any participant
currently in the room (proven by a valid LiveKit token for that room)
can mute.
"""
def has_object_permission(self, request, view, obj):
"""Check if the requesting user is allowed to mute a participant in the given room."""
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
# Always allow admins/owners when authenticated with session cookie
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
return True
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
if not everyone_can_mute:
return False
if not is_livekit_token_auth:
return False
# LiveKit token scoped to this room
return request.auth.video.room == str(obj.id)
+8 -30
View File
@@ -13,8 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer
from pydantic import ValidationError as PydanticValidationError
from pydantic import BaseModel, Field
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -132,16 +131,6 @@ class RoomSerializer(serializers.ModelSerializer):
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, value):
"""Validate room configuration against the RoomConfiguration schema."""
if value is None or value == {}:
return value
try:
RoomConfiguration.model_validate(value)
except PydanticValidationError as e:
raise serializers.ValidationError(e.errors()) from e
return value
def to_representation(self, instance):
"""
Add users only for administrator users.
@@ -166,6 +155,11 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
del output["configuration"]
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
@@ -182,7 +176,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id,
user=request.user,
username=username,
configuration=output["configuration"],
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
@@ -312,19 +306,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
class RoomConfiguration(BaseModel):
"""Validate room configuration structure.
Unknown fields are rejected.
"""
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
model_config = {"extra": "forbid"}
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel):
@@ -346,10 +328,6 @@ class ParticipantPermission(BaseModel):
model_config = {"extra": "forbid"}
@field_serializer("can_publish_sources")
def _serialize_sources(self, sources: list[str]) -> list[str]:
return [s.upper() for s in sources]
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
+12 -138
View File
@@ -6,9 +6,7 @@ from logging import getLogger
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
@@ -33,16 +31,12 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.settings import api_settings
from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -80,11 +74,6 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
@@ -308,41 +297,6 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
room = serializer.save()
if (
room.configuration == old_configuration
and room.access_level == old_access_level
):
return
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action(
detail=True,
methods=["post"],
@@ -366,27 +320,16 @@ class RoomViewSet(
options = serializer.validated_data.get("options")
room = self.get_object()
try:
with transaction.atomic():
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
models.RecordingAccess.objects.create(
user=self.request.user,
role=models.RoleChoices.OWNER,
recording=recording,
)
# May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create(
room=room,
mode=mode,
options=options.model_dump(exclude_none=True) if options else {},
)
except (DjangoValidationError, IntegrityError):
# DjangoValidationError covers the Python-level check (full_clean);
# IntegrityError covers the race where two concurrent requests both
# pass that check and the DB-level UNIQUE constraint catches the loser.
return drf_response.Response(
{"error": f"A recording is already in progress for room {room.slug}"},
status=drf_status.HTTP_409_CONFLICT,
)
models.RecordingAccess.objects.create(
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
)
worker_service = get_worker_service(mode=recording.mode)
worker_manager = WorkerServiceMediator(worker_service=worker_service)
@@ -394,12 +337,9 @@ class RoomViewSet(
try:
worker_manager.start(recording)
except RecordingStartError:
models.Recording.objects.filter(pk=recording.pk).update(
status=models.RecordingStatusChoices.FAILED_TO_START
)
return drf_response.Response(
{"error": f"Recording failed to start for room {room.slug}"},
status=drf_status.HTTP_502_BAD_GATEWAY,
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if settings.METADATA_COLLECTOR_ENABLED and (
@@ -407,7 +347,6 @@ class RoomViewSet(
):
try:
MetadataCollectorService().start(recording)
logger.debug("Started MetadataCollectorService")
except MetadataCollectorException:
logger.warning("Failed to start MetadataCollectorService")
@@ -658,11 +597,7 @@ class RoomViewSet(
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.CanMuteParticipant],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
@@ -671,26 +606,6 @@ class RoomViewSet(
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
# joined. Cross-check identity against the live participant list until auth
# is hardened. Skipped for non-LiveKit auth backends.
caller_identity = getattr(request.auth, "identity", None)
if caller_identity is not None:
try:
ParticipantsManagement().check_if_in_meeting(
room_name=str(room.pk),
identity=caller_identity,
)
except (ParticipantNotFoundException, ParticipantsManagementException):
logger.warning(
"Failed to verify caller presence for mute in room %s; denying",
room.pk,
)
return drf_response.Response(
{"error": "Could not verify caller presence"},
status=drf_status.HTTP_403_FORBIDDEN,
)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
@@ -998,47 +913,6 @@ class RecordingViewSet(
{"message": "Event processed."},
)
@decorators.action(
detail=False,
methods=["post"],
url_path="external-process-hook",
authentication_classes=[RecordingProcessWebhookAuthentication],
)
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming external process events for recordings."""
logger.debug("Processing external process event %s", request.data)
data = request.data
if not data.get("job_id"):
raise drf_exceptions.ValidationError(detail="No job_id provided")
job_id = data["job_id"]
try:
recording = models.Recording.objects.get(external_process_id=job_id)
except models.Recording.DoesNotExist as e:
logger.warning("No recording found for job_id %s: %s", job_id, e)
recording = None
changed = False
if recording and data.get("type") == "transcript":
if data.get("status") == "success":
logger.info("External process received for recording %s", job_id)
recording.status = (
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
)
changed = True
if data.get("status") == "failure":
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
changed = True
if changed and recording:
recording.save()
else:
logger.info("No changes to save for external process id %s", job_id)
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
@@ -1,23 +0,0 @@
# Generated by Django 5.2.14 on 2026-05-26 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_rename_active_application_is_active'),
]
operations = [
migrations.AddField(
model_name='recording',
name='external_process_id',
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
),
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
-18
View File
@@ -60,11 +60,6 @@ class RecordingStatusChoices(models.TextChoices):
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
EXTERNAL_PROCESS_SUCCESSFUL = (
"external_process_successful",
_("External process successful"),
)
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
@classmethod
def is_final(cls, status):
@@ -78,8 +73,6 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@@ -395,7 +388,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
# Public configuration exposed to any room participant via the API
configuration = models.JSONField(
blank=True,
default=dict,
@@ -598,14 +590,6 @@ class Recording(BaseModel):
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
external_process_id = models.CharField(
max_length=255,
null=True,
blank=True,
unique=True,
verbose_name=_("External Process ID"),
help_text=_("ID of the external process associated with the recording."),
)
class Meta:
db_table = "meet_recording"
@@ -661,8 +645,6 @@ class Recording(BaseModel):
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
@property
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated storage operations."""
def __init__(self, username: str = "storage_event_user") -> None:
def __init__(self) -> None:
self.pk = None
self.username = username
self.username = "storage_event_user"
self.is_active = True
@property
@@ -91,29 +91,3 @@ class StorageEventAuthentication(BaseAuthentication):
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Storage event API'"
class RecordingProcessWebhookAuthentication(BaseAuthentication):
"""
Custom authentication class for recording process webhook requests.
Validates the API key in the Authorization header.
"""
def authenticate(self, request):
"""
Authenticate the request and return a two-tuple of (user, token).
"""
logger.info("Authentificating")
authorization_header: str = request.headers.get("Authorization") or ""
if not secrets.compare_digest(
authorization_header.removeprefix("Bearer "),
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN,
):
logger.warning(
"Authentication failed: Bad Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed()
return MachineUser("external_process_user"), None
+10 -124
View File
@@ -1,11 +1,7 @@
"""Service to notify external services when a new recording is ready."""
import asyncio
import json
import logging
import smtplib
import warnings
from datetime import datetime, timezone
from django.conf import settings
from django.core.mail import send_mail
@@ -13,13 +9,9 @@ from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core.utils import generate_download_s3_file_url
from core import models
logger = logging.getLogger(__name__)
@@ -139,50 +131,7 @@ class NotificationService:
return not has_failures
@staticmethod
async def _get_recording_timestamps(worker_id):
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
FileInfo.started_at is more accurate than EgressInfo.started_at because
it reflects when file recording actually began. The started_at value exposed
in the manifest file, as well as in the EgressInfo returned by the API,
corresponds to when the egress service received the request, not the moment
the egress worker effectively joined the room.
Returns:
Tuple of (started_at, ended_at) datetimes, either may be None.
"""
if not worker_id:
return None, None
custom_configuration = {
**settings.LIVEKIT_CONFIGURATION,
"timeout": aiohttp.ClientTimeout(total=10),
}
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
try:
egress_list = await lkapi.egress.list_egress(
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
)
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
logger.exception("Could not fetch egress info for worker %s", worker_id)
return None, None
finally:
await lkapi.aclose()
if not egress_list.items or not egress_list.items[0].file_results:
logger.debug("No file_results for worker %s", worker_id)
return None, None
file_result = egress_list.items[0].file_results[0]
def _ns_to_utc(ns):
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
@staticmethod
def _notify_summary_service(recording: models.Recording):
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
if (
@@ -201,62 +150,24 @@ class NotificationService:
.first()
)
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
"collect_metadata", False
):
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
else:
metadata_filename = None
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
payload = {
# Legacy V1 params to avoid a breaking change
"owner_id": str(owner_access.user.id),
"recording_filename": recording.key,
"metadata_filename": metadata_filename,
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"owner_timezone": str(owner_access.user.timezone),
"language": recording.options.get("language"),
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
"recording_start_at": (started_at.isoformat() if started_at else None),
"recording_end_at": (ended_at.isoformat() if ended_at else None),
# V2 params
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"cloud_storage_url": generate_download_s3_file_url(
recording.key, expires_in=60 * 60 * 24, override_domain=False
),
"language": recording.options.get("language", "fr"),
"context_language": owner_access.user.language,
"push_to_docs_config": {
"user_email": owner_access.user.email,
"title": "TODO",
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
# For now the feature flag logic is handled on summary side
"auto_create_summary": True,
},
"metadata": (
{
"cloud_storage_url": generate_download_s3_file_url(
metadata_filename,
expires_in=60 * 60 * 24,
override_domain=False,
),
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
}
if (started_at and ended_at)
else None
),
}
headers = {
@@ -272,31 +183,6 @@ class NotificationService:
timeout=30,
)
response.raise_for_status()
is_v2_implementation = False
try:
response_json = response.json()
# We do not require a job_id to avoid a breaking change
if job_id := response_json.get("job_id"):
recording.external_process_id = job_id
recording.save()
is_v2_implementation = True
except json.JSONDecodeError:
pass
if not is_v2_implementation:
warnings.warn(
"You are likely using your own implementation for the summary / "
"transcribe service."
"We have released a new version and API contract for that service, "
"we recommend checking it out"
# pylint: disable=line-too-long
"https://github.com/suitenumerique/meet/blob/19c2a378e7e66652afbfa3e749badd697e3917ac/src/summary/summary/api/route/tasks_v2.py "
"and mimicking it's behavior. We will remove the legacy "
"handling in a future version.",
DeprecationWarning,
stacklevel=2,
)
except requests.RequestException as exc:
logger.exception(
"Summary service error for recording %s. URL: %s. Exception: %s",
@@ -1,7 +1,5 @@
"""Factory, configurations and Protocol to create worker services"""
# pylint: disable=no-member
import logging
from dataclasses import dataclass
from functools import lru_cache
@@ -10,17 +8,8 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
from django.conf import settings
from django.utils.module_loading import import_string
from livekit import api as livekit_api
logger = logging.getLogger(__name__)
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
# Kept fixed because changing them would shift the goal-post away from the
# "safe drop-in replacement for the default preset" contract of this feature.
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
@dataclass(frozen=True)
class WorkerServiceConfig:
@@ -29,7 +18,6 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
bucket_args: Optional[dict]
encoding_options: Optional[Dict[str, Any]] = None
@classmethod
@lru_cache
@@ -37,24 +25,6 @@ class WorkerServiceConfig:
"""Load configuration from Django settings with caching for efficiency."""
logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None
if settings.RECORDING_ENCODING_ENABLED:
# Single source of truth for the EncodingOptions kwargs:
# operator-tunable values live in Django settings, codec / frequency
# are pinned constants. The services layer only unpacks this dict.
encoding_options = {
"width": settings.RECORDING_ENCODING_WIDTH,
"height": settings.RECORDING_ENCODING_HEIGHT,
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
@@ -66,7 +36,6 @@ class WorkerServiceConfig:
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
"force_path_style": True,
},
encoding_options=encoding_options,
)
+3 -27
View File
@@ -83,22 +83,6 @@ class BaseEgressService:
"""
raise NotImplementedError("Subclass must implement this method.")
def _build_encoding_options(self):
"""Build a LiveKit EncodingOptions from the service config, or None.
When None is returned, the caller should omit the `advanced` field so
LiveKit Egress falls back to its built-in preset (H264_720P_30).
The full EncodingOptions kwargs (operator-tunable values + pinned
codec / frequency constants) are assembled in `WorkerServiceConfig`,
so this method is a thin protobuf adapter.
"""
opts = self._config.encoding_options
if not opts:
return None
return livekit_api.EncodingOptions(**opts)
class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
@@ -120,17 +104,9 @@ class VideoCompositeEgressService(BaseEgressService):
s3=self._s3,
)
request_kwargs = {
"room_name": room_name,
"file_outputs": [file_output],
"layout": "speaker-light",
}
advanced = self._build_encoding_options()
if advanced is not None:
request_kwargs["advanced"] = advanced
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
)
response = self._handle_request(request, "start_room_composite_egress")
+1 -1
View File
@@ -123,7 +123,7 @@ class LobbyService:
def request_entry(
self,
room: models.Room,
room,
request,
username: str,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
@@ -15,7 +15,6 @@ from livekit.api import (
TwirpError,
UpdateParticipantRequest,
)
from livekit.protocol.models import ParticipantInfo
from core import utils
@@ -155,44 +154,3 @@ class ParticipantsManagement:
finally:
await lkapi.aclose()
@async_to_sync
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
"""Check whether `identity` is currently a participant in `room_name`.
Raises ParticipantsManagementException for unexpected LiveKit errors
so callers can fail closed rather than silently allowing the action.
"""
if not room_name or not identity:
return False
lkapi = utils.create_livekit_client()
try:
participant = await lkapi.room.get_participant(
RoomParticipantIdentity(
room=room_name,
identity=identity,
)
)
except TwirpError as e:
if e.code == "not_found":
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception(
"Unexpected error checking participant %s in room %s",
identity,
room_name,
)
raise ParticipantsManagementException(
"Could not verify participant presence"
) from e
finally:
await lkapi.aclose()
return (
participant is not None
and participant.state != ParticipantInfo.State.DISCONNECTED
)
@@ -1,64 +0,0 @@
"""Room management service for LiveKit rooms."""
# pylint: disable=no-name-in-module
import json
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
TwirpError,
UpdateRoomMetadataRequest,
)
from core import utils
logger = getLogger(__name__)
class RoomManagementException(Exception):
"""Exception raised when a room management operation fails."""
class RoomNotFoundException(RoomManagementException):
"""Raised when the target room does not exist in LiveKit."""
class RoomManagement:
"""Service for managing LiveKit rooms."""
@async_to_sync
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
"""Update a LiveKit room's metadata.
The `room_name` corresponds to the LiveKit room identifier
(i.e. the Room model's UUID as a string).
"""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=json.dumps(metadata) if metadata is not None else "",
)
)
except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e
logger.exception(
"Unexpected error updating metadata for room %s",
room_name,
)
raise RoomManagementException("Could not update room metadata") from e
finally:
await lkapi.aclose()
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
policy_parsed = urlparse(policy)
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
assert policy_parsed.netloc == "localhost:9000"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
@@ -1,134 +0,0 @@
"""
Test recordings API endpoints: external process hook.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from ...factories import RecordingFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
@pytest.fixture
def external_process_settings(settings):
"""Configure authentication token for the external process webhook."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
return settings
def test_external_process_event_missing_authorization_header(
external_process_settings, client
):
"""Requests without authorization must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
)
assert response.status_code == 403
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
"""Requests with invalid bearer token must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer wrongToken",
)
assert response.status_code == 403
def test_external_process_event_missing_job_id(external_process_settings, client):
"""Payload without job_id must fail validation."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 400
assert "No job_id provided" in str(response.json())
def test_external_process_event_success_updates_recording_status(
external_process_settings, client
):
"""A successful transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-123",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-123", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
def test_external_process_event_failure_updates_recording_status(
external_process_settings, client
):
"""A failing transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-456",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-456", "type": "transcript", "status": "failure"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
def test_external_process_event_unknown_recording_is_ignored(
external_process_settings, client
):
"""Unknown job_id should not fail the webhook processing."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "missing-job", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
def test_external_process_event_non_transcript_event_does_not_change_status(
external_process_settings, client
):
"""Only transcript events should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-789",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED
@@ -2,7 +2,7 @@
Test worker service factories.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
@@ -10,9 +10,6 @@ from unittest.mock import Mock
from django.test import override_settings
import pytest
from livekit import (
api as livekit_api_codec,
)
from core.recording.worker.factories import (
WorkerService,
@@ -66,8 +63,6 @@ def test_config_initialization(default_config):
"bucket": "test-bucket",
"force_path_style": True,
}
# Encoding override is opt-in; disabled by default.
assert default_config.encoding_options is None
def test_config_immutability(default_config):
@@ -76,45 +71,6 @@ def test_config_immutability(default_config):
default_config.output_folder = "new/path"
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280,
RECORDING_ENCODING_HEIGHT=720,
RECORDING_ENCODING_FRAMERATE=15,
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
)
def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
The dict mixes operator-tunable values from settings with pinned codec /
frequency constants, so the services layer can simply unpack it.
"""
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options == {
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 600,
"audio_bitrate": 64,
"key_frame_interval": 10.0,
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
"audio_codec": livekit_api_codec.AudioCodec.AAC,
"audio_frequency": 48000,
}
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -39,31 +39,6 @@ def config():
)
@pytest.fixture
def config_with_encoding(config):
"""Fixture for a config carrying custom encoding options.
Mirrors the dict shape produced by `WorkerServiceConfig.from_settings()`
(operator-tunable values + pinned codec / frequency constants).
"""
return WorkerServiceConfig(
output_folder=config.output_folder,
server_configurations=config.server_configurations,
bucket_args=config.bucket_args,
encoding_options={
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 600,
"audio_bitrate": 64,
"key_frame_interval": 10.0,
"video_codec": livekit_api.VideoCodec.H264_MAIN,
"audio_codec": livekit_api.AudioCodec.AAC,
"audio_frequency": 48000,
},
)
@pytest.fixture
def mock_s3_upload():
"""Fixture for mocked S3Upload"""
@@ -249,41 +224,6 @@ def test_video_composite_egress_start_missing_egress_id(video_service):
assert "Egress ID not found" in str(exc_info.value)
def test_video_composite_egress_start_without_encoding_options(video_service):
"""When no encoding options are configured, no `advanced` field is set.
LiveKit then falls back to its built-in preset (H264_720P_30).
"""
video_service._handle_request.return_value = Mock(egress_id="eg-1")
video_service.start("test-room", "rec-1")
request = video_service._handle_request.call_args[0][0]
# Proto oneof `options` must be unset when no advanced encoding is provided.
assert request.WhichOneof("options") is None
def test_video_composite_egress_start_with_encoding_options(config_with_encoding):
"""Custom encoding options are forwarded as `advanced` EncodingOptions."""
service = VideoCompositeEgressService(config_with_encoding)
service._handle_request = Mock(return_value=Mock(egress_id="eg-2"))
service.start("test-room", "rec-2")
request = service._handle_request.call_args[0][0]
assert request.WhichOneof("options") == "advanced"
advanced = request.advanced
assert advanced.width == 1280
assert advanced.height == 720
assert advanced.framerate == 15
assert advanced.video_bitrate == 600
assert advanced.audio_bitrate == 64
assert advanced.key_frame_interval == pytest.approx(10.0)
assert advanced.video_codec == livekit_api.VideoCodec.H264_MAIN
assert advanced.audio_codec == livekit_api.AudioCodec.AAC
assert advanced.audio_frequency == 48000
def test_audio_composite_egress_hrid(audio_service):
"""Test HRID is correct"""
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
@@ -2,23 +2,20 @@
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import random
from unittest import mock
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
import pytest
from livekit.api import TwirpError, UpdateParticipantRequest
from livekit.protocol.models import ParticipantInfo
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
@@ -34,8 +31,8 @@ def mock_livekit_client():
yield mock_client
def test_mute_participant_success_as_admin(mock_livekit_client):
"""Admins and owners should be able to mute without a LiveKit token."""
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -44,12 +41,10 @@ def test_mute_participant_success_as_admin(mock_livekit_client):
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
@@ -58,131 +53,23 @@ def test_mute_participant_success_as_admin(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user is anonymous and no LiveKit token."""
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
"""Should allow muting when the LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_with_livekit_token_for_another_room_forbidden(
mock_livekit_client,
):
"""Should forbid muting when the LiveKit token is scoped to a different room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_authenticated_no_role_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user has no room role and no LiveKit token."""
client = APIClient()
room = RoomFactory() # everyone_can_mute defaults to True
user = UserFactory() # no UserResourceAccess for this room
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
mock_livekit_client,
):
"""Should forbid muting when everyone_can_mute is False, even with a LiveKit token."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
"""Should allow admins and owners to mute when everyone_can_mute is False."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_invalid_payload():
"""Should reject muting when the payload is invalid."""
"""Test mute participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -191,16 +78,16 @@ def test_mute_participant_invalid_payload():
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Should return 500 when the LiveKit API raises a TwirpError."""
"""Test mute participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
@@ -214,12 +101,10 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
@@ -227,282 +112,6 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_participant_not_found(mock_livekit_client):
"""Should return 404 when the participant does not exist in the room."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_management_exception(mock_livekit_client):
"""Should return 500 when ParticipantsManagement raises an unexpected error."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="boom", code="internal", status=503
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
"""Should allow muting when user is admin and LiveKit token is scoped to this room."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
# Token identity matches the admin user so LiveKitTokenAuthentication
# resolves request.user back to the admin.
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client):
"""Should not allow muting when user is admin and the LiveKit token is for another room."""
client = APIClient()
target_room = RoomFactory()
other_room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=target_room,
user=user,
role=random.choice(["administrator", "owner"]),
)
# Token is scoped to a DIFFERENT room, and admin status must only be
# honored when established via session, never via a LiveKit
# token, which can be replayed off-host.
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"detail": "You do not have permission to perform this action."
}
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_admin_token_replayed_does_not_grant_admin(
mock_livekit_client,
):
"""Should forbid muting when a LiveKit token issued for an admin is passed without a session."""
client = APIClient()
room = RoomFactory(configuration={"everyone_can_mute": False})
admin_user = UserFactory()
UserResourceAccessFactory(
resource=room,
user=admin_user,
role=random.choice(["administrator", "owner"]),
)
# The token is the only credential.
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_client):
"""Should check participant presence when authenticated via LiveKit token only."""
client = APIClient()
room = RoomFactory()
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
# Presence is verified against LiveKit before the mute is issued.
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_returns_participant(
mock_livekit_client,
):
"""Should mute when the authentified participant is currently in the room."""
client = APIClient()
room = RoomFactory()
# Simulate LiveKit confirming the caller is currently in the room.
# State != DISCONNECTED (3) means present.
mock_livekit_client.room.get_participant.return_value = ParticipantInfo(
identity="caller-identity",
state=ParticipantInfo.State.ACTIVE,
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.get_participant.assert_called_once()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_mute_participant_livekit_token_presence_check_participant_not_found(
mock_livekit_client,
):
"""Should not mute when the authentified participant is not found."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
mock_livekit_client,
):
"""Should not mute when the presence check fail."""
client = APIClient()
room = RoomFactory()
mock_livekit_client.room.get_participant.side_effect = TwirpError(
msg="an error occured", code="not_found", status=500
)
user = AnonymousUser()
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {"error": "Could not verify caller presence"}
mock_livekit_client.room.get_participant.assert_called_once()
# The presence check failed, so we never reach the mute call.
mock_livekit_client.room.mute_published_track.assert_not_called()
def test_mute_participant_session_auth_skips_presence_check(mock_livekit_client):
"""Should not check presence of the participant when authentified with a session cookie."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(
url,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
assert response.status_code == status.HTTP_200_OK
# Session auth has no LiveKit identity to verify against, so the
# stop-gap presence check is skipped.
mock_livekit_client.room.get_participant.assert_not_called()
mock_livekit_client.room.mute_published_track.assert_called_once()
def test_update_participant_success(mock_livekit_client):
"""Test successful participant update."""
client = APIClient()
@@ -521,8 +130,8 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
"camera",
"microphone",
"CAMERA",
"MICROPHONE",
],
"can_update_metadata": True,
"can_subscribe_metrics": True,
@@ -549,8 +158,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_publish_data": True},
{
"can_publish_sources": [
"camera",
"microphone",
"CAMERA",
"MICROPHONE",
]
},
{"can_update_metadata": True},
@@ -581,41 +190,9 @@ def test_update_participant_permission_fields_are_optional(
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
(request_arg,), _ = mock_livekit_client.room.update_participant.call_args
assert isinstance(request_arg, UpdateParticipantRequest)
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_permission_fields_invalid_case(mock_livekit_client):
"""Should raise bad request when can_publish_sources is uppercase."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {
"can_publish_sources": [
"CAMERA",
"microphone",
]
},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
mock_livekit_client.room.update_participant.assert_not_called()
mock_livekit_client.aclose.assert_not_called()
@pytest.mark.parametrize(
"value,permission_key",
[
@@ -28,7 +28,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -48,7 +47,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "trusted",
"id": str(room.id),
"is_administrable": False,
@@ -67,7 +65,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -84,7 +81,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -101,7 +97,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -205,7 +200,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
assert response.status_code == 200
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -238,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"""
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["camera"]},
configuration={"can_publish_sources": ["mock-source"]},
)
user = UserFactory()
@@ -252,7 +246,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -271,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
user=user,
username=None,
color=None,
sources=["camera"],
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -304,7 +297,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"configuration": {},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -346,7 +338,6 @@ def test_api_rooms_retrieve_authenticated():
assert response.status_code == 200
assert response.json() == {
"configuration": {},
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
@@ -372,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
other_user = UserFactory()
room = RoomFactory(
configuration={"can_publish_sources": ["camera"]},
configuration={"can_publish_sources": ["mock-source"]},
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
@@ -392,7 +383,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
expected_name = str(room.id)
assert content_dict == {
"configuration": {"can_publish_sources": ["camera"]},
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
@@ -411,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
user=user,
username=None,
color=None,
sources=["camera"],
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@@ -140,18 +140,16 @@ def test_start_recording_worker_error(
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
assert response.status_code == 502
assert response.status_code == 500
assert response.json() == {
"error": f"Recording failed to start for room {room.slug}"
}
# Recording object should be created even if worker fails, and moved out
# of the unique-constraint window so the room is not locked.
# Recording object should be created even if worker fails
assert Recording.objects.count() == 1
recording = Recording.objects.first()
assert recording.room == room
assert recording.mode == "screen_recording"
assert recording.status == "failed_to_start"
# Verify recording access details
assert recording.accesses.count() == 1
@@ -160,72 +158,6 @@ def test_start_recording_worker_error(
assert access.role == "owner"
@pytest.mark.parametrize(
"status",
["active", "initiated"],
)
def test_start_recording_conflict_when_already_in_progress(
status, mock_worker_service_factory, mock_worker_manager, settings
):
"""Should return 409 when a second start is attempted while a recording is already active."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
# Pre-existing active recording for the same room.
Recording.objects.create(room=room, mode="screen_recording", status="active")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert response.status_code == 409
assert response.json() == {
"error": f"A recording is already in progress for room {room.slug}"
}
# No new recording row, no access row leaked from the rolled-back transaction.
assert Recording.objects.count() == 1
assert Recording.objects.first().accesses.count() == 0
mock_worker_manager.start.assert_not_called()
def test_start_recording_after_worker_failure_unblocks_room(
mock_worker_service_factory, mock_worker_manager, settings
):
"""Should allow a new recording when the previous recording failed."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
mock_worker_manager.start = mock.Mock(
side_effect=[RecordingStartError("boom"), None]
)
client = APIClient()
client.force_login(user)
first = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert first.status_code == 502
second = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording"},
)
assert second.status_code == 201
assert Recording.objects.count() == 2
def test_start_recording_success(
mock_worker_service_factory, mock_worker_manager, settings
):
@@ -3,18 +3,12 @@ Test rooms API endpoints in the Meet core app: update.
"""
import random
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
pytestmark = pytest.mark.django_db
@@ -73,7 +67,7 @@ def test_api_rooms_update_members():
"name": "New name",
"slug": "should-be-ignored",
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"can_publish_sources": ["camera", "microphone"]},
"configuration": {"the_key": "the_value"},
},
format="json",
)
@@ -85,14 +79,12 @@ def test_api_rooms_update_members():
assert room.configuration == {}
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators(mock_update_metadata):
"""Should sync LiveKit metadata when both configuration and access level change."""
def test_api_rooms_update_administrators():
"""Administrators or owners of a room should be allowed to update it."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
)
client = APIClient()
client.force_login(user)
@@ -103,7 +95,7 @@ def test_api_rooms_update_administrators(mock_update_metadata):
"name": "New name",
"slug": "should-be-ignored",
"access_level": RoomAccessLevel.PUBLIC,
"configuration": {"can_publish_sources": ["camera", "microphone"]},
"configuration": {"the_key": "the_value"},
},
format="json",
)
@@ -112,252 +104,7 @@ def test_api_rooms_update_administrators(mock_update_metadata):
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_configuration_only(mock_update_metadata):
"""Should sync LiveKit metadata when only configuration changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "restricted",
"configuration": {"can_publish_sources": ["camera", "microphone"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_access_level_only(mock_update_metadata):
"""Should sync LiveKit metadata when only access level changes."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
configuration={"can_publish_sources": ["camera"]},
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"access_level": RoomAccessLevel.PUBLIC,
},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": "public",
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrators_name_only(mock_update_metadata):
"""Should not sync LiveKit metadata when neither configuration nor access level changes."""
user = UserFactory()
room = RoomFactory(
name="Old name",
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["camera"]},
users=[(user, random.choice(["administrator", "owner"]))],
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"name": "New name"},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
# Unrelated fields untouched
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"configuration",
[
{"can_publish_sources": ["camera", "microphone"]},
{
"can_publish_sources": [
"camera",
"microphone",
"screen_share",
"screen_share_audio",
]
},
{"can_publish_sources": []},
{"can_publish_sources": None},
{"can_publish_sources": None, "everyone_can_mute": True},
{"can_publish_sources": None, "everyone_can_mute": False},
{"can_publish_sources": None, "everyone_can_mute": "yes"},
{"can_publish_sources": None, "everyone_can_mute": "1"},
],
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration):
"""Administrators should be allowed to set valid configurations."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": configuration},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == configuration
mock_update_metadata.assert_called_once()
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_unchanged_empty(mock_update_metadata):
"""Should not sync LiveKit metadata when patching an already empty configuration."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={})
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
def test_api_rooms_update_configuration_extra_keys_rejected():
"""Extra keys in configuration should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{
"configuration": {
"can_publish_sources": ["camera"],
"arbitrary_key": "value",
}
},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
@pytest.mark.parametrize("invalid_source", ["invalid_source", "CAMERA"])
def test_api_rooms_update_configuration_invalid_source_value(invalid_source):
"""Invalid source values should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": [invalid_source]}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
def test_api_rooms_update_configuration_wrong_type():
"""Configuration values with wrong types should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": "camera"}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
@pytest.mark.parametrize("invalid_value", ["test", [], {}])
def test_api_rooms_update_configuration_everyone_can_mute_wrong_type(invalid_value):
"""everyone_can_mute values with wrong types should be rejected."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"everyone_can_mute": invalid_value}},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
assert room.configuration == {"the_key": "the_value"}
def test_api_rooms_update_administrators_of_another():
@@ -379,61 +126,3 @@ def test_api_rooms_update_administrators_of_another():
other_room.refresh_from_db()
assert other_room.name == "Old name"
assert other_room.slug == "old-name"
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
"""Should not fail the API request when the LiveKit room does not exist yet."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
"""Should not fail the API request when the LiveKit metadata sync fails."""
user = UserFactory()
room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
-35
View File
@@ -455,38 +455,3 @@ def generate_upload_policy(file):
)
return policy
def generate_download_s3_file_url(
key, *, expires_in: int, override_domain: bool = True
):
"""
Generate a S3 signed download url for a given 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 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
return s3_client.generate_presigned_url(
ClientMethod="get_object",
Params={"Bucket": default_storage.bucket_name, "Key": key},
ExpiresIn=expires_in,
)
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+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"
@@ -88,17 +88,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/api/serializers.py:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
@@ -146,59 +146,51 @@ msgstr "Stopp fehlgeschlagen"
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "ID"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "erstellt am"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -206,11 +198,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "Sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -218,55 +210,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "Kurzname"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "Sprache"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "Gerät"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "aktiv"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -274,66 +266,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "Benutzer"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "Benutzer"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:345
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:411
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Raum"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Räume"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -342,149 +334,141 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:743
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:760
#: core/models.py:742
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:763
#: core/models.py:745
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:788
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:799
#: core/models.py:781
msgid "Application"
msgstr "Anwendung"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:858
#: core/models.py:840
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Bereit"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:908
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:913
#: core/models.py:895
msgid "File"
msgstr "Datei"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Dateien"
#: core/models.py:1018
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1028
#: core/models.py:1010
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -492,7 +476,7 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -592,10 +576,8 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+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"
@@ -88,15 +88,15 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
@@ -144,59 +144,51 @@ msgstr "Failed to Stop"
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "created on"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "updated on"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -204,11 +196,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -216,55 +208,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "full name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "short name"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "language"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "device"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "staff status"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "active"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -272,63 +264,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "user"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "users"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Resource"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Resources"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:345
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:411
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Room"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -336,159 +328,151 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Recording"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:743
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:760
#: core/models.py:742
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:763
#: core/models.py:745
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Application name"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:788
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:799
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:858
#: core/models.py:840
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Ready"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Background image"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "title"
#: core/models.py:908
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:913
#: core/models.py:895
msgid "File"
msgstr "File"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Files"
#: core/models.py:1018
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1028
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -588,10 +572,8 @@ msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -89,17 +89,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/api/serializers.py:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
@@ -147,61 +147,53 @@ msgstr "Échec à l'arrêt"
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "créé le"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -209,11 +201,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -221,55 +213,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "nom complet"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "nom court"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "langue"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "appareil"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "actif"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -277,65 +269,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "utilisateur"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "utilisateurs"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Ressources"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:345
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:411
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Salle"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Salles"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -343,158 +335,150 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:743
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:760
#: core/models.py:742
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:763
#: core/models.py:745
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:788
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:799
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domaine"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:858
#: core/models.py:840
msgid "Pending"
msgstr "En attente"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Prêt"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "Titre"
#: core/models.py:908
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:913
#: core/models.py:895
msgid "File"
msgstr "Fichier"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1018
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1028
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -594,10 +578,8 @@ msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
+82 -100
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"POT-Creation-Date: 2026-04-21 14:09+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"
@@ -88,16 +88,16 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:89
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1228
#: core/api/viewsets.py:1090
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
@@ -145,59 +145,51 @@ msgstr "Stoppen mislukt"
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -205,11 +197,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -217,55 +209,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "volledige naam"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "korte naam"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "taal"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "apparaat"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "actief"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -273,64 +265,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "gebruiker"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "gebruikers"
#: core/models.py:279
#: core/models.py:272
msgid "Resource"
msgstr "Bron"
#: core/models.py:280
#: core/models.py:273
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:338
#: core/models.py:331
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:339
#: core/models.py:332
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:345
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:402
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:403
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:410
#: core/models.py:402
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:411
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:417 core/models.py:571
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Ruimte"
#: core/models.py:418
#: core/models.py:410
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:582
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:584
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -338,148 +330,140 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:592
#: core/models.py:584
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:593
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:598 core/models.py:599
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
#: core/models.py:597
msgid "Recording"
msgstr "Opname"
#: core/models.py:614
#: core/models.py:598
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:724
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:725
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:731
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:737
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:743
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:760
#: core/models.py:742
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:761
#: core/models.py:743
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:762
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:763
#: core/models.py:745
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:764
#: core/models.py:746
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:777
#: core/models.py:759
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:778
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:788
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:799
#: core/models.py:781
msgid "Application"
msgstr "Applicatie"
#: core/models.py:800
#: core/models.py:782
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:823
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:826
#: core/models.py:808
msgid "Domain"
msgstr "Domein"
#: core/models.py:827
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:839
#: core/models.py:821
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:840
#: core/models.py:822
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:858
#: core/models.py:840
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:866
#: core/models.py:848
msgid "Ready"
msgstr "Klaar"
#: core/models.py:872
#: core/models.py:854
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:884
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:908
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:913
#: core/models.py:895
msgid "File"
msgstr "Bestand"
#: core/models.py:914
#: core/models.py:896
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1018
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1028
#: core/models.py:1010
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -487,7 +471,7 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:124
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -587,10 +571,8 @@ msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
-46
View File
@@ -700,53 +700,12 @@ class Base(Configuration):
RECORDING_MAX_DURATION = values.IntegerValue(
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
)
# Recording encoding options for LiveKit Egress (video composite egress only).
# These settings affect screen recordings handled by VideoCompositeEgressService;
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
# recordings), whose request never carries advanced EncodingOptions.
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
# When enabled, the values below are passed to LiveKit as EncodingOptions
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
# output file size and CPU load on the egress worker.
RECORDING_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
)
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
)
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
)
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
)
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
3000,
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
environ_prefix=None,
)
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
128,
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
environ_prefix=None,
)
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
4.0,
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
environ_prefix=None,
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
@@ -858,11 +817,6 @@ class Base(Configuration):
environ_name="METADATA_COLLECTOR_AGENT_NAME",
environ_prefix=None,
)
METADATA_COLLECTOR_OUTPUT_FOLDER = values.Value(
"metadata",
environ_name="METADATA_COLLECTOR_OUTPUT_FOLDER",
environ_prefix=None,
)
# External Applications
APPLICATION_ENABLED = values.BooleanValue(
+2 -3
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.16.0"
version = "1.15.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -40,7 +40,7 @@ dependencies = [
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.14",
"django==5.2.13",
"djangorestframework==3.16.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.3.4",
@@ -61,7 +61,6 @@ dependencies = [
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0",
"aiohttp==3.13.4",
"urllib3==2.7.0",
]
[project.urls]
+8 -10
View File
@@ -573,16 +573,16 @@ wheels = [
[[package]]
name = "django"
version = "5.2.14"
version = "5.2.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
]
[[package]]
@@ -1173,7 +1173,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.16.0"
version = "1.15.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1212,7 +1212,6 @@ dependencies = [
{ name = "redis" },
{ name = "requests" },
{ name = "sentry-sdk" },
{ name = "urllib3" },
{ name = "whitenoise" },
]
@@ -1244,7 +1243,7 @@ requires-dist = [
{ name = "brotli", specifier = "==1.2.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
{ name = "dj-database-url", specifier = "==3.1.2" },
{ name = "django", specifier = "==5.2.14" },
{ name = "django", specifier = "==5.2.13" },
{ name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" },
@@ -1274,7 +1273,6 @@ requires-dist = [
{ name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.33.0" },
{ name = "sentry-sdk", specifier = "==2.54.0" },
{ name = "urllib3", specifier = "==2.7.0" },
{ name = "whitenoise", specifier = "==6.12.0" },
]
@@ -2262,11 +2260,11 @@ wheels = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
-3
View File
@@ -1,3 +0,0 @@
declare module '@fontsource-variable/lexend' {}
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
declare module '@fontsource/opendyslexic' {}
+353 -458
View File
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.16.0",
"version": "1.15.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,11 +13,8 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11",
"@fontsource-variable/material-symbols-outlined": "5.2.34",
"@fontsource/material-icons-outlined": "5.2.6",
"@fontsource/opendyslexic": "5.2.5",
"@livekit/components-react": "2.9.19",
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.0",
@@ -47,7 +44,7 @@
"wouter": "3.9.0"
},
"devDependencies": {
"@pandacss/dev": "1.11.1",
"@pandacss/dev": "1.8.2",
"@tanstack/eslint-plugin-query": "5.91.4",
"@tanstack/react-query-devtools": "5.91.3",
"@types/humanize-duration": "3.27.4",
@@ -62,7 +59,7 @@
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.14",
"postcss": "8.5.6",
"prettier": "3.8.1",
"typescript": "5.8.3",
"vite": "7.3.2",
-1
View File
@@ -279,7 +279,6 @@ const config: Config = {
'room-side-panel-margin': { value: '1.5rem' },
'room-control-bar': { value: '80px' },
'room-reaction-toolbar-height': { value: '42px' },
'tooltip-spacing': { value: '8px' },
},
spacing,
}),
-121
View File
@@ -1,121 +0,0 @@
<svg width="102" height="72" viewBox="0 0 102 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13227_1168)">
<g filter="url(#filter0_d_13227_1168)">
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#969EB0"/>
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#969EB0" stroke-width="1.1926"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#181B24" stroke-opacity="0.6" stroke-width="1.1926"/>
<g filter="url(#filter1_d_13227_1168)">
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter2_d_13227_1168)">
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter3_d_13227_1168)">
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter4_d_13227_1168)">
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
</g>
<g filter="url(#filter5_d_13227_1168)">
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#7E98FF"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="url(#paint0_linear_13227_1168)" fill-opacity="0.05"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#7E98FF" stroke-width="1.1926"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#181B24" stroke-opacity="0.45" stroke-width="1.1926"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#FF706E"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#181B24" fill-opacity="0.45"/>
</g>
<g filter="url(#filter6_d_13227_1168)">
<path d="M50.3653 53.4428C51.0628 53.4428 51.6941 53.2682 52.2593 52.9191C52.8304 52.576 53.2844 52.1135 53.6211 51.5316C53.9638 50.9558 54.1352 50.3156 54.1352 49.6112C54.1352 48.9006 53.9638 48.2543 53.6211 47.6724C53.2844 47.0965 52.8304 46.634 52.2593 46.2849C51.6941 45.9418 51.0628 45.7703 50.3653 45.7703H47.344C46.1836 45.7703 45.1555 45.6539 44.2596 45.4211C43.3637 45.1884 42.5731 44.7871 41.8876 44.2174C41.2022 43.6539 40.598 42.8698 40.0749 41.8652C39.9185 41.5711 39.7412 41.3843 39.5428 41.3046C39.3504 41.225 39.158 41.1852 38.9656 41.1852C38.7251 41.1852 38.5086 41.2924 38.3162 41.5068C38.1298 41.7151 38.0366 42.0183 38.0366 42.4165C38.0366 44.1133 38.217 45.6386 38.5777 46.9924C38.9445 48.3523 39.5037 49.5131 40.2552 50.4749C41.0128 51.4366 41.9778 52.1717 43.1503 52.6801C44.3287 53.1886 45.7266 53.4428 47.344 53.4428H50.3653ZM47.6056 42.2603V56.9161C47.6056 57.2224 47.7048 57.4858 47.9032 57.7063C48.1076 57.9268 48.3692 58.0371 48.6878 58.0371C48.9043 58.0371 49.0997 57.985 49.274 57.8809C49.4544 57.7829 49.6649 57.6175 49.9054 57.3847L57.0212 50.6035C57.1955 50.4381 57.3158 50.2697 57.3819 50.0981C57.4481 49.9266 57.4811 49.7643 57.4811 49.6112C57.4811 49.4641 57.4481 49.3049 57.3819 49.1333C57.3158 48.9618 57.1955 48.7934 57.0212 48.628L49.9054 41.7825C49.6889 41.5742 49.4815 41.4241 49.2831 41.3322C49.0907 41.2342 48.8862 41.1852 48.6698 41.1852C48.3631 41.1852 48.1076 41.2863 47.9032 41.4884C47.7048 41.6906 47.6056 41.9478 47.6056 42.2603Z" fill="#969EB0"/>
</g>
</g>
<defs>
<filter id="filter0_d_13227_1168" x="7.82784" y="4.69151" width="68.1964" height="57.826" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter1_d_13227_1168" x="17.3569" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter2_d_13227_1168" x="17.3569" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter3_d_13227_1168" x="39.394" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter4_d_13227_1168" x="39.394" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter5_d_13227_1168" x="44.1242" y="30.6175" width="50.0479" height="44.8629" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter6_d_13227_1168" x="29.8645" y="37.0992" width="35.7887" height="33.1961" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<linearGradient id="paint0_linear_13227_1168" x1="69.1483" y1="34.7036" x2="69.1483" y2="63.2222" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6F8F9" stop-opacity="0.975"/>
<stop offset="1" stop-color="#F6F8F9" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_13227_1168">
<rect width="102" height="72" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 9.7 KiB

-2
View File
@@ -14,14 +14,12 @@ import './i18n/init'
import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
function App() {
const { i18n } = useTranslation()
useTitle(import.meta.env.VITE_APP_TITLE ?? '')
const isSDKContext = useIsSdkContext()
useApplyA11yFonts()
return (
<QueryClientProvider client={queryClient}>
+1 -3
View File
@@ -2,8 +2,6 @@ import { fetchApi } from './fetchApi'
import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording'
import { Track } from 'livekit-client'
import Source = Track.Source
export interface ApiConfig {
analytics?: {
@@ -52,7 +50,7 @@ export interface ApiConfig {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: Source[]
default_sources: string[]
}
transcription_destination?: string
}
@@ -12,7 +12,7 @@ const controlBarRegion = cva({
variants: {
mobile: {
true: {
justifyContent: 'center',
justifyContent: 'space-between',
width: '330px',
},
},
@@ -22,8 +22,6 @@ const controlBarRegion = cva({
},
})
export const CONTROL_BAR_REGION_ID = 'control-bar-region'
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
RecipeVariantProps<typeof controlBarRegion>
@@ -36,7 +34,6 @@ export function ControlBarRegion({
return (
<div
role="region"
id={CONTROL_BAR_REGION_ID}
aria-label={t('controls.region')}
className={controlBarRegion({ mobile })}
{...props}
@@ -3,17 +3,18 @@ import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useTranslation } from 'react-i18next'
import { Div } from '@/primitives'
import { NotificationType } from './NotificationType'
import { NotificationDuration } from './NotificationDuration'
import { decodeNotificationDataReceived } from './utils'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { toastQueue } from './components/ToastProvider'
import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
import { NotificationProvider } from './NotificationProvider'
export const MainNotificationToast = () => {
const room = useRoomContext()
@@ -232,5 +233,10 @@ export const MainNotificationToast = () => {
// the 'notifications' namespace might not be loaded yet
useTranslation(['notifications'])
return <NotificationProvider />
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
</Div>
)
}
@@ -1,16 +0,0 @@
import { Div } from '@/primitives'
import { ToastProvider } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
export const NotificationProvider = ({
bottom = 0,
right = 5,
}: {
bottom?: number
right?: number
}) => (
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
<ToastProvider />
<WaitingParticipantNotification />
</Div>
)
@@ -1,8 +0,0 @@
import { PictureInPicturePortal } from '@/features/pip/components/PictureInPicturePortal'
import { PipView } from '@/features/pip/components/PipView'
export const PictureInPictureConference = () => (
<PictureInPicturePortal>
<PipView />
</PictureInPicturePortal>
)
@@ -1,56 +0,0 @@
import { usePictureInPicture } from '../hooks/usePictureInPicture'
import { createPortal } from 'react-dom'
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useSnapshot } from 'valtio'
import { useEffect, useMemo } from 'react'
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
const InternalPortal = ({ children }: { children: React.ReactNode }) => {
const pipStoreSnap = useSnapshot(documentPictureInPictureStore)
const container = useMemo(() => {
return pipStoreSnap?.window?.document.getElementById('root')
}, [pipStoreSnap.window])
useEffect(() => {
return () => {
documentPictureInPictureStore.window?.close()
}
}, [])
if (!container) return null
return createPortal(
/**
* UNSAFE_PortalProvider is marked unsafe because overlays are normally
* portalled to `document.body` to avoid clipping, stacking, and accessibility
* issues. Redirecting them to another container can break those guarantees.
*
* We accept that risk here because the PiP window is a separate document with
* its own `body`. Rendering overlays into the main document would make them
* invisible in PiP, so we intentionally portal them into the PiP document root
* instead.
*/
<UNSAFE_PortalProvider getContainer={() => container}>
{/*React Aria computes overlay position based on the main window size, so*/}
{/*we must disable it to position overlays correctly across documents.*/}
<CrossDocumentOverlaysContext.Provider value={true}>
{children}
</CrossDocumentOverlaysContext.Provider>
</UNSAFE_PortalProvider>,
container
)
}
export const PictureInPicturePortal = ({
children,
}: {
children: React.ReactNode
}): React.ReactNode => {
const { isSupported } = usePictureInPicture()
if (!isSupported) return null
return <InternalPortal>{children}</InternalPortal>
}
@@ -1,116 +0,0 @@
import { styled } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { AudioDevicesControl } from '@/features/rooms/livekit/components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '@/features/rooms/livekit/components/controls/Device/VideoDeviceControl'
import { ScreenShareToggle } from '@/features/rooms/livekit/components/controls/ScreenShareToggle'
import { LeaveButton } from '@/features/rooms/livekit/components/controls/LeaveButton'
import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle'
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
import { PipOptionsMenu } from './controls/PipOptionsMenu'
import { usePipElementSize } from '../hooks/usePipElementSize'
import { useMemo, useRef } from 'react'
export const CollapsibleControls = {
HAND: 'hand',
SCREEN_SHARE: 'screenShare',
REACTIONS: 'reactions',
} as const
export type CollapsibleControl =
(typeof CollapsibleControls)[keyof typeof CollapsibleControls]
const COLLAPSE_ORDER: CollapsibleControl[] = [
CollapsibleControls.HAND,
CollapsibleControls.SCREEN_SHARE,
CollapsibleControls.REACTIONS,
]
const BUTTON_SLOT = 50
const ESSENTIAL_WIDTH = 260
const getHiddenControls = (
containerWidth: number,
showScreenShare: boolean
): Set<CollapsibleControl> => {
const hidden = new Set<CollapsibleControl>()
if (containerWidth <= 0) return hidden
const collapsible = showScreenShare
? COLLAPSE_ORDER
: COLLAPSE_ORDER.filter((c) => c !== CollapsibleControls.SCREEN_SHARE)
const available = containerWidth - ESSENTIAL_WIDTH
const maxVisible = Math.max(0, Math.floor(available / BUTTON_SLOT))
for (let i = 0; i < collapsible.length - maxVisible; i++) {
hidden.add(collapsible[i])
}
return hidden
}
export const PipControlBar = ({
showScreenShare,
}: {
showScreenShare: boolean
}) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width } = usePipElementSize(containerRef)
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const hidden = useMemo(
() => getHiddenControls(width, showScreenShare),
[width, showScreenShare]
)
return (
<PipControls
ref={containerRef}
id="pip-control-bar"
role="toolbar"
aria-label={t('controlBar')}
>
<PipControlsCenter>
<AudioDevicesControl hideMenu />
<VideoDeviceControl hideMenu />
{!hidden.has(CollapsibleControls.REACTIONS) && <ReactionsToggle />}
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
<ScreenShareToggle />
)}
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
<StartMediaButton />
<PipOptionsMenu overflowControls={hidden} />
<LeaveButton />
</PipControlsCenter>
</PipControls>
)
}
const PipControls = styled('div', {
base: {
flex: '0 0 auto',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '0.5rem',
padding: '1.125rem',
width: '100%',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
})
const PipControlsCenter = styled('div', {
base: {
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'center',
alignItems: 'center',
gap: '0.4rem',
flex: '1 1 auto',
},
})
@@ -1,46 +0,0 @@
import { useSnapshot } from 'valtio'
import { reactionsStore } from '@/stores/reactions'
import { useMemo, useRef } from 'react'
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
import { Reaction } from '@/features/reactions/types'
import { css } from '@/styled-system/css'
export const PipFloatingReactions = () => {
const { reactions } = useSnapshot(reactionsStore)
return (
<>
{reactions.map((reaction) => (
<PipFloatingReaction key={reaction.id} reaction={reaction} />
))}
</>
)
}
const PipFloatingReaction = ({ reaction }: { reaction: Reaction }) => {
const containerRef = useRef<HTMLDivElement>(null)
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return (
<div
ref={containerRef}
className={css({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'hidden',
})}
>
<FloatingReaction
emoji={reaction.emoji}
speed={speed}
scale={scale}
name={reaction.participantName}
isLocal={reaction.isLocal}
/>
</div>
)
}
@@ -1,48 +0,0 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { usePictureInPicture } from '../hooks/usePictureInPicture'
import { Button, Text } from '@/primitives'
export const PipRoomPlaceholder = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture.placeholder',
})
const { close } = usePictureInPicture()
return (
<Container>
<img
src="/assets/pip.svg"
alt=""
width={300}
height={72}
aria-hidden="true"
style={{
marginBottom: '0.25rem',
}}
/>
<Text variant="body">{t('title')}</Text>
<Text variant="sm" style={{ maxWidth: '312px' }}>
{t('description')}
</Text>
<Button variant="primaryTextDark" onPress={close}>
{t('bringBack')}
</Button>
</Container>
)
}
const Container = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
gap: '0.5rem',
padding: '1.5rem',
textAlign: 'center',
color: 'white',
},
})
@@ -1,76 +0,0 @@
import { styled } from '@/styled-system/jsx'
import { PipControlBar } from './PipControlBar'
import { PipFloatingReactions } from './PipFloatingReactions'
import { PipStage } from './layout/PipStage'
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { NotificationProvider } from '@/features/notifications/NotificationProvider'
import { ConnectionStateToast } from '@livekit/components-react'
const Container = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
display: 'grid',
gridTemplateRows: 'minmax(0, 1fr) auto auto',
backgroundColor: 'primaryDark.50',
transition: 'padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
// Disable LiveKit's own border-radius on tiles so our containers
// (GridCell, Thumbnail, StageFrame) own the clipping exclusively.
'--lk-border-radius': '4px',
'& .lk-participant-tile': {
height: '100%',
},
'& .lk-participant-media': {
height: '100%',
},
'& .lk-participant-media-video': {
height: '100%',
objectFit: 'cover',
},
'& .lk-grid-layout': {
height: '100%',
width: '100%',
},
},
variants: {
isReactionToolbarOpen: {
true: {
paddingBottom:
'calc(var(--sizes-room-reaction-toolbar-height) + var(--sizes-room-control-bar) + 1.125rem)',
},
false: {
paddingBottom: 'var(--sizes-room-control-bar)',
},
},
},
})
const ConnectionStateWrapper = styled('div', {
base: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
pointerEvents: 'none',
'& > *': { pointerEvents: 'auto' },
},
})
export const PipView = () => {
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return (
<Container isReactionToolbarOpen={isReactionToolbarOpen}>
<ConnectionStateWrapper>
<ConnectionStateToast />
</ConnectionStateWrapper>
<PipStage />
<ReactionsToolbar adjustedCentering={false} />
<PipControlBar showScreenShare={false} />
<PipFloatingReactions />
<NotificationProvider bottom={30} />
</Container>
)
}
@@ -1,129 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from '@react-aria/focus'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useDismissOnEscape } from '../../hooks/useDismissOnEscape'
import { CollapsibleControl } from '@/features/pip/components/PipControlBar'
type PipOptionsMenuProps = {
overflowControls: Set<CollapsibleControl>
}
/**
* PiP-native options menu.
*
* Why not use the shared `<Menu>` primitive (React Aria's MenuTrigger +
* Popover)?
*
* React Aria positions popovers by reading `window.innerWidth` and
* `window.innerHeight` to know where the viewport edges are. The problem:
* it reads them from the *module-global* `window`, which is always the
* main browser window even when the trigger button lives inside the
* Picture-in-Picture window (a separate `document` with its own, smaller
* viewport). React Aria therefore thinks it has the full main-window
* space to work with, and places the popover using those coordinates.
* The result is a menu that appears off-screen, clipped, or in the wrong
* corner of the PiP.
*
* The same single-document assumption breaks focus: React Aria's focus
* management and outside-click detection listen on the main `document`,
* so when the user interacts in the PiP, the menu doesn't receive focus
* on open, Escape doesn't restore focus to the trigger, and clicking
* outside doesn't dismiss it.
*
* These aren't bugs we can fix from the outside the `window` and
* `document` references are baked into React Aria internals, with no
* prop or context to override them.
*
* So in PiP we replace the primitive with this component.
*/
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const label = t('options.buttonLabel')
useDismissOnEscape(wrapperRef, isOpen, () => {
setIsOpen(false)
requestAnimationFrame(() => triggerRef.current?.focus())
})
useEffect(() => {
if (!isOpen) return
const doc = wrapperRef.current?.ownerDocument ?? document
const handleMenuItemClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.querySelector('button')?.contains(target)) return
if (target.closest('[role="menuitem"]')) {
requestAnimationFrame(() => {
setIsOpen(false)
triggerRef.current?.focus()
})
}
}
const handleOutsideClick = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.contains(target)) return
setIsOpen(false)
}
doc.addEventListener('click', handleMenuItemClick, true)
doc.addEventListener('mousedown', handleOutsideClick, true)
return () => {
doc.removeEventListener('click', handleMenuItemClick, true)
doc.removeEventListener('mousedown', handleOutsideClick, true)
}
}, [isOpen])
return (
<div
ref={wrapperRef}
className={css({
position: 'relative',
})}
>
<Button
ref={triggerRef}
id="room-options-trigger"
square
variant="primaryDark"
aria-label={label}
aria-haspopup="menu"
aria-expanded={isOpen}
tooltip={label}
onPress={() => setIsOpen(!isOpen)}
>
<RiMoreFill />
</Button>
{isOpen && (
<div
className={css({
position: 'absolute',
left: '50%',
bottom: 'calc(100% + 0.85rem)',
transform: 'translateX(-50%)',
zIndex: 10,
})}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems overflowControls={overflowControls} />
</Box>
</FocusScope>
</div>
)}
</div>
)
}
@@ -1,71 +0,0 @@
import { Menu as RACMenu, MenuItem } from 'react-aria-components'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
import { CollapsibleControl, CollapsibleControls } from '../PipControlBar'
import { RiArrowUpLine, RiEmotionLine, RiHand } from '@remixicon/react'
import { menuRecipe } from '@/primitives/menuRecipe.ts'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
import { useRoomContext, useTrackToggle } from '@livekit/components-react'
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
import { useTranslation } from 'react-i18next'
import { Track } from 'livekit-client'
type PipOverflowItemsProps = {
overflowControls: Set<CollapsibleControl>
}
export const PipOptionsMenuItems = ({
overflowControls,
}: PipOverflowItemsProps) => {
const { t } = useTranslation('rooms')
const room = useRoomContext()
const { isHandRaised, toggleRaisedHand } = useRaisedHand({
participant: room.localParticipant,
})
const { buttonProps: screenShareProps, enabled: isScreenSharing } =
useTrackToggle({
source: Track.Source.ScreenShare,
captureOptions: { audio: true, selfBrowserSurface: 'include' },
})
const { toggle: toggleReactions } = useReactionsToolbar()
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
return (
<RACMenu
style={{
minWidth: '150px',
width: '300px',
}}
>
<PictureInPictureMenuItem />
{overflowControls.has(CollapsibleControls.REACTIONS) && (
<MenuItem onAction={toggleReactions} className={itemClass}>
<RiEmotionLine size={20} />
{t('controls.reactions.button')}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.SCREEN_SHARE) && (
<MenuItem
onAction={() =>
screenShareProps.onClick?.(
{} as React.MouseEvent<HTMLButtonElement>
)
}
className={itemClass}
>
<RiArrowUpLine size={20} />
{t(
isScreenSharing
? 'controls.screenShare.stop'
: 'controls.screenShare.start'
)}
</MenuItem>
)}
{overflowControls.has(CollapsibleControls.HAND) && (
<MenuItem onAction={toggleRaisedHand} className={itemClass}>
<RiHand size={20} />
{isHandRaised ? t('controls.hand.lower') : t('controls.hand.raise')}
</MenuItem>
)}
</RACMenu>
)
}
@@ -1,81 +0,0 @@
import { memo } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipFocusLayoutProps = {
mainTrack?: TrackReferenceOrPlaceholder
thumbnailTrack?: TrackReferenceOrPlaceholder
}
/**
* Focus layout used when 1-2 tracks are visible in the PiP window.
*
* The main tile is letterboxed (object-fit: contain) so the camera is
* never stretched to a non-video aspect and leaves dark padding
* above/below when the window shape doesn't match the source.
* The thumbnail keeps the usual cover fill.
*/
export const PipFocusLayout = memo(
({ mainTrack, thumbnailTrack }: PipFocusLayoutProps) => {
return (
<FocusContainer>
{mainTrack && (
<MainSlot>
<ParticipantTile
key={getTrackKey(mainTrack)}
trackRef={mainTrack}
/>
</MainSlot>
)}
{thumbnailTrack && (
<Thumbnail>
<ParticipantTile
key={getTrackKey(thumbnailTrack)}
trackRef={thumbnailTrack}
/>
</Thumbnail>
)}
</FocusContainer>
)
}
)
PipFocusLayout.displayName = 'PipFocusLayout'
const FocusContainer = styled('div', {
base: {
position: 'relative',
width: '100%',
height: '100%',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
},
})
const MainSlot = styled('div', {
base: {
width: '100%',
height: '100%',
'& .lk-participant-media-video': {
objectFit: 'contain',
},
},
})
const Thumbnail = styled('div', {
base: {
position: 'absolute',
right: '1rem',
bottom: '1rem',
width: '42%',
maxWidth: '220px',
minWidth: '140px',
aspectRatio: '16 / 9',
borderRadius: '4px',
overflow: 'hidden',
boxShadow: 'md',
zIndex: 2,
},
})
@@ -1,76 +0,0 @@
import { memo, useMemo, useRef } from 'react'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { styled } from '@/styled-system/jsx'
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
import { usePipElementSize } from '../../hooks/usePipElementSize'
import { computePipGridLayout } from '../../utils/pipGrid'
import { getTrackKey } from '../../utils/pipTrackSelection'
type PipGridLayoutProps = {
tracks: TrackReferenceOrPlaceholder[]
}
/**
* Adaptive grid used when 3+ tracks are visible in the PiP window.
*
* All grid math (shape choice + partial-row stretching) is delegated to
* `computePipGridLayout`. This component only measures the container,
* applies the returned placements, and plays a FLIP animation when the
* tile set or grid shape changes (participant joins/leaves or shape shift).
*
* Tiles keep a stable key so resizing never remounts <video> elements.
*/
export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const { width, height } = usePipElementSize(containerRef)
const { rows, subColumns, placements } = useMemo(
() => computePipGridLayout(tracks.length, width, height),
[tracks.length, width, height]
)
const gridStyle = useMemo(
() => ({
gridTemplateColumns: `repeat(${subColumns}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
}),
[subColumns, rows]
)
return (
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell key={getTrackKey(track)} style={placements[index]}>
<ParticipantTile trackRef={track} />
</GridCell>
))}
</GridContainer>
)
})
PipGridLayout.displayName = 'PipGridLayout'
const GridContainer = styled('div', {
base: {
width: '100%',
height: '100%',
display: 'grid',
gap: '0.25rem',
},
})
const GridCell = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
// Paint on own layer so FLIP transforms don't trigger layout thrash.
willChange: 'transform',
'& .lk-participant-tile': {
width: '100%',
height: '100%',
},
},
})
@@ -1,77 +0,0 @@
import { useMemo } from 'react'
import { useTracks } from '@livekit/components-react'
import { RoomEvent, Track } from 'livekit-client'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
import { StageFrame } from './StageFrame'
import {
isTrackReference,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
/**
* PipStage picks between two layouts based on track count:
* - Focus mode ( 2 tracks): one main track + one thumbnail overlay.
* - Grid mode (3+ tracks): adaptive tiling.
*/
export const PipStage = () => {
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
)
const screenShareTrack = useMemo(() => {
return tracks
.filter((track) => isTrackReference(track))
.find((track) => track.publication.source === Track.Source.ScreenShare)
}, [tracks])
const cameraTracks = useMemo(
() =>
tracks.filter(
(track: TrackReferenceOrPlaceholder) =>
track.source === Track.Source.Camera
),
[tracks]
)
if (tracks.length === 0) return null
/**
* The focus layout shows one main track with one thumbnail overlay,
* so it can only fit 2 tracks. Beyond that we switch to the grid.
*/
if (tracks.length > 2) {
// Grid mode: 3+ tracks. Screen share goes first so it leads the grid.
const gridTracks = screenShareTrack
? [screenShareTrack, ...cameraTracks]
: cameraTracks
return (
<StageFrame>
<PipGridLayout tracks={gridTracks} />
</StageFrame>
)
}
const localCameraTrack = cameraTracks.find(
(track) => track.participant?.isLocal
)
const remoteCameraTrack = cameraTracks.find(
(track) => !track.participant?.isLocal
)
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
const thumbnailTrack =
mainTrack === localCameraTrack ? undefined : localCameraTrack
return (
<StageFrame>
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
</StageFrame>
)
}
@@ -1,25 +0,0 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
return (
<Container role="region" aria-label={t('stage')} {...{ inert: '' }}>
{children}
</Container>
)
}
const Container = styled('div', {
base: {
position: 'relative',
minWidth: 0,
minHeight: 0,
marginLeft: '0.5rem',
marginRight: '0.5rem',
borderRadius: '4px',
overflow: 'hidden',
},
})
@@ -1,28 +0,0 @@
import { useEffect, useRef, type RefObject } from 'react'
export const useDismissOnEscape = (
ref: RefObject<HTMLElement | null>,
isActive: boolean,
onDismiss: () => void
) => {
const latestOnDismiss = useRef(onDismiss)
useEffect(() => {
latestOnDismiss.current = onDismiss
})
useEffect(() => {
if (!isActive) return
const el = ref.current
if (!el) return
const handler = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return
event.preventDefault()
event.stopPropagation()
latestOnDismiss.current()
}
el.addEventListener('keydown', handler)
return () => el.removeEventListener('keydown', handler)
}, [ref, isActive])
}
@@ -1,116 +0,0 @@
import { ref, useSnapshot } from 'valtio'
import { useCallback, useMemo } from 'react'
import { documentPictureInPictureStore } from '@/stores/documentPictureInPicture'
import { useTranslation } from 'react-i18next'
export const IS_PIP_SUPPORTED =
typeof globalThis !== 'undefined' && 'documentPictureInPicture' in globalThis
export const usePictureInPicture = () => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const { window: pipWindowRef } = useSnapshot(documentPictureInPictureStore)
const isOpen = useMemo(() => {
return !!pipWindowRef && !pipWindowRef.closed
}, [pipWindowRef])
const syncStyles = useCallback((pipWindow: Window) => {
document.head
.querySelectorAll('link[rel="stylesheet"], style')
.forEach((node) => {
pipWindow.document.head.appendChild(node.cloneNode(true))
})
pipWindow.document.documentElement.className =
document.documentElement.className
pipWindow.document.documentElement.style.cssText =
document.documentElement.style.cssText
const theme = document.documentElement.dataset.lkTheme
if (theme) {
pipWindow.document.documentElement.dataset.lkTheme = theme
}
}, [])
const initializePortalContainer = useCallback((pipWindow: Window) => {
const existing = pipWindow.document.getElementById('root')
if (existing) return existing
const newContainer = pipWindow.document.createElement('div')
newContainer.id = 'root'
newContainer.style.width = '100%'
newContainer.style.height = '100%'
pipWindow.document.body.appendChild(newContainer)
}, [])
const initializeTitleAndLanguage = useCallback(
(pipWindow: Window, title: string) => {
const parentLang = document?.documentElement.lang || 'en'
pipWindow.document.documentElement.setAttribute('lang', parentLang)
pipWindow.document.title = title
},
[]
)
const open = useCallback(
async (width = 400, height = 480) => {
if (!IS_PIP_SUPPORTED) return null
if (isOpen) return null
try {
const pipWindow =
await // eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).documentPictureInPicture.requestWindow({
width,
height,
})
initializeTitleAndLanguage(pipWindow, t('title'))
initializePortalContainer(pipWindow)
syncStyles(pipWindow)
const cleanUp = () => {
if (documentPictureInPictureStore.window === pipWindow) {
documentPictureInPictureStore.window = null
}
}
pipWindow.addEventListener('pagehide', () => cleanUp(), { once: true })
pipWindow.addEventListener('beforeunload', () => cleanUp(), {
once: true,
})
documentPictureInPictureStore.window = ref(pipWindow)
} catch (error) {
// Avoid unhandled rejections if the user blocks or closes the request.
console.error('Failed to open Picture-in-Picture window', error)
return null
}
},
[
initializePortalContainer,
initializeTitleAndLanguage,
isOpen,
syncStyles,
t,
]
)
const close = useCallback(() => {
documentPictureInPictureStore.window?.close()
documentPictureInPictureStore.window = null
}, [])
const toggle = useCallback(async () => {
if (isOpen) close()
else await open()
}, [isOpen, close, open])
return {
isSupported: IS_PIP_SUPPORTED,
isOpen,
open,
close,
toggle,
}
}
@@ -1,42 +0,0 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
type Size = { width: number; height: number }
/**
* Observes an element's size, even when mounted in the PiP document.
* Resolves `ResizeObserver` from the element's own window.
*/
export const usePipElementSize = <T extends HTMLElement>(
ref: RefObject<T | null>
): Size => {
const [size, setSize] = useState<Size>({ width: 0, height: 0 })
const measure = useCallback(() => {
const el = ref.current
if (!el) return
const rect = el.getBoundingClientRect()
setSize({ width: rect.width, height: rect.height })
}, [ref])
useEffect(() => {
const el = ref.current
if (!el) return
measure()
const RO =
el.ownerDocument.defaultView?.ResizeObserver ?? globalThis.ResizeObserver
if (!RO) return
const observer = new RO((entries) => {
const entry = entries[0]
if (!entry) return
const { width, height } = entry.contentRect
setSize({ width, height })
})
observer.observe(el)
return () => observer.disconnect()
}, [ref, measure])
return size
}
@@ -1,114 +0,0 @@
export type PipTilePlacement = {
gridColumn: string
gridRow: number
}
export type PipGridLayout = {
cols: number
rows: number
/** Number of CSS sub-columns; use as `repeat(subColumns, 1fr)`. */
subColumns: number
/** One entry per tile, in input order. */
placements: PipTilePlacement[]
}
/**
* Target tile aspect ratio used to score candidate grid shapes.
*
* Video sources are 16:9, but picking 16:9 as the target makes the
* scorer indifferent between a stretched 2-col slab (aspect ~2.7) and a
* squarer 3-col tile (aspect ~1.2) because log distance is symmetric.
* The UI works better with square, face-friendly tiles. This target keeps
* wide windows from collapsing to 2 columns with short, stretched rows
* and pushes the scorer to add a column instead.
*/
const TARGET_TILE_ASPECT = 1
/**
* Smallest count from which we force at least two columns.
* For 1-3 participants it is acceptable to stack vertically in tall
* windows, but from 4 people onwards we keep >=2 columns to
* avoid endless vertical scrolling; the scorer handles the rest.
*/
const FORCE_TWO_COLS_COUNT = 4
const pickGridShape = (
count: number,
width: number,
height: number
): { cols: number; rows: number } => {
if (count <= 1) return { cols: 1, rows: Math.max(1, count) }
if (width <= 0 || height <= 0) return { cols: count, rows: 1 }
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
let best = {
cols: minCols,
rows: Math.ceil(count / minCols),
score: -Infinity,
}
for (let cols = minCols; cols <= count; cols++) {
const rows = Math.ceil(count / cols)
const tileW = width / cols
const tileH = height / rows
if (tileW <= 0 || tileH <= 0) continue
// Score: aspect close to target, few empty cells, large tile area,
// and a tiny bias toward fewer rows so ties (perfectly square shapes)
// resolve in favour of a shorter, wider grid.
const aspectScore = -Math.abs(Math.log(tileW / tileH / TARGET_TILE_ASPECT))
const emptyCells = cols * rows - count
const fillScore = -emptyCells * 0.1
const areaScore = Math.log(tileW * tileH) * 0.5
const rowsPenalty = -rows * 0.01
const score = aspectScore * 2 + fillScore + areaScore + rowsPenalty
if (score > best.score) best = { cols, rows, score }
}
return { cols: best.cols, rows: best.rows }
}
/**
* Pure function. Given a tile count and stage dimensions, returns the CSS
* grid layout for the PiP stage:
*
* - picks a cols x rows shape close to 16:9 tiles,
* - stretches any partial last row so its tiles share the full row width
* (no empty cells, no small centered tile).
*
* Callers consume the result directly: `subColumns` feeds
* `grid-template-columns: repeat(N, 1fr)` and each tile reads its own
* `gridColumn`/`gridRow` from `placements`.
*/
export const computePipGridLayout = (
count: number,
width: number,
height: number
): PipGridLayout => {
if (count <= 0) {
return { cols: 1, rows: 1, subColumns: 1, placements: [] }
}
const { cols, rows } = pickGridShape(count, width, height)
const tilesInLastRow = count - cols * (rows - 1)
const hasPartialRow = tilesInLastRow > 0 && tilesInLastRow < cols
const subColumns = hasPartialRow ? cols * tilesInLastRow : cols
const fullRowSpan = hasPartialRow ? tilesInLastRow : 1
const lastRowSpan = hasPartialRow ? cols : 1
const placements: PipTilePlacement[] = []
for (let i = 0; i < count; i++) {
const row = Math.floor(i / cols)
const colIndex = i % cols
const isLastRow = row === rows - 1 && hasPartialRow
const span = isLastRow ? lastRowSpan : fullRowSpan
const colStart = colIndex * span + 1
placements.push({
gridColumn: `${colStart} / span ${span}`,
gridRow: row + 1,
})
}
return { cols, rows, subColumns, placements }
}
@@ -1,16 +0,0 @@
import {
isTrackReference,
TrackReferenceOrPlaceholder,
} from '@livekit/components-core'
/**
* Produces a stable React key for a track so resizes/reshuffles of the grid
* do not remount the underlying <video> element.
*/
export const getTrackKey = (track: TrackReferenceOrPlaceholder): string => {
const identity = track.participant?.identity ?? 'unknown'
if (isTrackReference(track)) {
return `${identity}::${track.source}::${track.publication.trackSid}`
}
return `${identity}::${track.source}::placeholder`
}
@@ -15,8 +15,6 @@ const focusReactionsToolbar = () => {
?.focus()
}
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
@@ -37,7 +35,7 @@ export const ReactionsToggle = () => {
return (
<ToggleButton
id={REACTIONS_TOGGLE_ID}
id="reactions-toggle"
data-attr="reactions-toggle"
square
variant="primaryDark"
@@ -1,227 +0,0 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { styled } from '@/styled-system/jsx'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { useIsMobile } from '@/utils/useIsMobile'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
import { FocusScope } from '@react-aria/focus'
import { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
import { REACTIONS_TOGGLE_ID } from '../ReactionsToggle'
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.2rem',
borderRadius: '21px',
backgroundColor: 'primaryDark.100',
maxWidth: '100%',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
},
})
const StyledScrollViewport = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
overflowX: 'auto',
padding: '0.19rem',
scrollBehavior: 'smooth',
minWidth: 0,
flex: '1 1 auto',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
'& > *': {
flexShrink: 0,
},
},
})
const SCROLL_AMOUNT = 120 // roughly 3 buttons
export const ReactionButtonsContainer = ({
children,
adjustedCentering = true,
}: {
children: React.ReactNode
adjustedCentering?: boolean
}) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const { width } = useSize(scrollRef)
const [isVisible, setIsVisible] = useState(false)
const [overflowing, setOverflowing] = useState(false)
const [atStart, setAtStart] = useState(true)
const [atEnd, setAtEnd] = useState(false)
const [
shouldBeCenteredWithToggleButton,
setShouldBeCenteredWithToggleButton,
] = useState(false)
const [rightOffset, setRightOffset] = useState(0)
const updateArrows = useCallback(() => {
const el = scrollRef.current
if (!el) return
setOverflowing(el.scrollWidth > el.clientWidth + 1)
setAtStart(el.scrollLeft <= 0)
setAtEnd(el.scrollLeft + el.clientWidth >= el.scrollWidth - 1)
}, [])
useEffect(() => {
if (isMobile || !adjustedCentering) return
const region = document.getElementById(CONTROL_BAR_REGION_ID)
if (!region) return
const check = () => {
setShouldBeCenteredWithToggleButton(
window.innerWidth > region.clientWidth
)
}
check()
const ro = new ResizeObserver(check)
ro.observe(region)
window.addEventListener('resize', check)
return () => {
ro.disconnect()
window.removeEventListener('resize', check)
}
}, [isMobile, adjustedCentering])
useLayoutEffect(() => {
if (!shouldBeCenteredWithToggleButton || isMobile) {
setRightOffset(0)
return
}
const container = containerRef.current
if (!container) return
let frame = 0
const align = () => {
const toggle = document.getElementById(REACTIONS_TOGGLE_ID)
if (!toggle) return
const toggleRect = toggle.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
const toggleCenterX = toggleRect.left + toggleRect.width / 2
const containerCenterX = containerRect.left + containerRect.width / 2
const shift = toggleCenterX - containerCenterX
if (Math.abs(shift) < 0.5) return
setRightOffset((prev) => prev - shift * 2)
}
const schedule = () => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(align)
}
schedule()
const ro = new ResizeObserver(schedule)
ro.observe(container)
const region = document.getElementById(CONTROL_BAR_REGION_ID)
if (region) ro.observe(region)
const toggle = document.getElementById(REACTIONS_TOGGLE_ID)
if (toggle) ro.observe(toggle)
window.addEventListener('resize', schedule)
return () => {
cancelAnimationFrame(frame)
ro.disconnect()
window.removeEventListener('resize', schedule)
}
}, [shouldBeCenteredWithToggleButton, isMobile, isOpen])
useEffect(() => {
if (isOpen) {
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
}
setIsVisible(false)
}, [isOpen])
useEffect(() => {
updateArrows()
}, [width, updateArrows])
const scrollBy = (delta: number) => {
scrollRef.current?.scrollBy({ left: delta, behavior: 'smooth' })
}
return (
<StyledContainer
ref={containerRef}
aria-hidden={!isOpen}
isVisible={isVisible}
style={
shouldBeCenteredWithToggleButton && !isMobile && adjustedCentering
? { marginRight: `${rightOffset}px` }
: { margin: '0 15px' }
}
>
{overflowing && (
<div aria-hidden="true">
<Button
onPress={() => scrollBy(-SCROLL_AMOUNT)}
variant="primaryTextDark"
size="sm"
isDisabled={atStart}
round
excludeFromTabOrder
>
<RiArrowLeftSLine />
</Button>
</div>
)}
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<ReactionsKeyboardNavigation>
<StyledScrollViewport ref={scrollRef} onScroll={updateArrows}>
{children}
</StyledScrollViewport>
</ReactionsKeyboardNavigation>
</FocusScope>
{overflowing && (
<div aria-hidden="true">
<Button
onPress={() => scrollBy(SCROLL_AMOUNT)}
variant="primaryTextDark"
size="sm"
isDisabled={atEnd}
round
excludeFromTabOrder
>
<RiArrowRightSLine />
</Button>
</div>
)}
</StyledContainer>
)
}
@@ -1,63 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useFocusManager } from '@react-aria/focus'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
type Props = {
children: React.ReactNode
toggleId?: string
controlBarId?: string
}
export const ReactionsKeyboardNavigation = ({
children,
toggleId = 'reactions-toggle',
controlBarId = 'control-bar',
}: Props) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const { close } = useReactionsToolbar()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
if (!e.target.matches(':focus-visible')) return
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById(toggleId)?.focus()
close()
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable(controlBarId)?.focus()
}
break
}
}
return (
<div
id={REACTIONS_TOOLBAR_ID}
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
@@ -1,9 +1,15 @@
import { FocusScope, useFocusManager } from '@react-aria/focus'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { ReactionButton } from './ReactionButton'
import { Emoji } from '../../types'
import { styled } from '@/styled-system/jsx'
import { layoutStore } from '@/stores/layout'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { useIsMobile } from '@/utils/useIsMobile'
import { useEffect, useRef, useState } from 'react'
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
import { ReactionButtonsContainer } from './ReactionButtonsContainer'
import { useTranslation } from 'react-i18next'
const Container = styled('div', {
base: {
@@ -17,11 +23,116 @@ const Container = styled('div', {
},
})
export const ReactionsToolbar = ({
adjustedCentering,
}: {
adjustedCentering?: boolean
}) => {
const StyledStrip = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
borderRadius: '21px',
padding: '0.15rem',
backgroundColor: 'primaryDark.100',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
desktopOffset: {
true: {
// Ideally this value should be calculated dynamically in JavaScript to keep
// the reaction toolbar perfectly centered relative to the reaction toggle.
// However, for simplicity and to follow a pragmatic 80/20 approach,
// this value is currently hardcoded in CSS.
marginRight: '30px',
},
},
},
})
const Strip = ({ children }: { children: React.ReactNode }) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
if (isOpen) {
// defer one frame so the browser paints opacity:0 first
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
} else {
setIsVisible(false)
}
}, [isOpen])
return (
<StyledStrip
ref={ref}
aria-hidden={!isOpen}
isVisible={isVisible}
desktopOffset={!isMobile}
>
{children}
</StyledStrip>
)
}
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById('reactions-toggle')?.focus()
layoutStore.showReactionsToolbar = false
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable('control-bar')?.focus()
}
break
}
}
return (
<div
id={REACTIONS_TOOLBAR_ID}
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
export const ReactionsToolbar = () => {
const { isOpen } = useReactionsToolbar()
const shouldMount = useDelayUnmount(isOpen, 300)
@@ -29,11 +140,16 @@ export const ReactionsToolbar = ({
return (
<Container>
<ReactionButtonsContainer adjustedCentering={adjustedCentering}>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</ReactionButtonsContainer>
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<KeyboardNavigation>
<Strip>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</Strip>
</KeyboardNavigation>
</FocusScope>
</Container>
)
}
@@ -9,8 +9,5 @@ export const useReactionsToolbar = () => {
toggle: () => {
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
},
close: () => {
layoutStore.showReactionsToolbar = false
},
}
}
@@ -1,6 +1,3 @@
import { Track } from 'livekit-client'
import Source = Track.Source
export type ApiLiveKit = {
url: string
room: string
@@ -13,11 +10,6 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted',
}
export type RoomConfiguration = {
can_publish_sources?: Source[] | null
everyone_can_mute?: boolean | null
}
export type ApiRoom = {
id: string
name: string
@@ -26,5 +18,7 @@ export type ApiRoom = {
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: RoomConfiguration
configuration?: {
[key: string]: string | number | boolean | string[]
}
}
@@ -6,74 +6,44 @@ import {
NotificationType,
} from '@/features/notifications'
import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
export const useMuteParticipant = () => {
const apiRoomData = useRoomData()
const data = useRoomData()
const { notifyParticipants } = useNotifyParticipants()
const isAdminOrOwner = useIsAdminOrOwner()
const muteParticipant = useCallback(
async (participant: Participant) => {
if (!apiRoomData?.livekit?.room) {
throw new Error('Room id is not available')
}
const muteParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
if (!trackSid) {
return
}
if (!trackSid) {
return
}
try {
const response = await fetchApi(`rooms/${data.id}/mute-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
})
// Guard against undefined token for non-admin users
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
console.error('Cannot mute participant: missing auth token')
return
}
const headers = !isAdminOrOwner
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
: undefined
let response
try {
response = await fetchApi(
`rooms/${apiRoomData.livekit.room}/mute-participant/`,
{
method: 'POST',
headers,
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
}
)
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
return
}
try {
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
} catch (e) {
console.error(
`Failed to notify muted participant ${participant.identity}: ${e}`
)
}
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
return response
},
[apiRoomData, isAdminOrOwner, notifyParticipants]
)
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { muteParticipant }
}
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
const updateParticipantPermissions = async (
participant: Participant,
sources: Source[]
sources: Array<Source>
) => {
if (!data?.id) {
throw new Error('Room id is not available')
@@ -20,7 +20,7 @@ export const useParticipantPermissions = () => {
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources,
can_publish_sources: sources.map((source) => source.toUpperCase()),
}
try {
@@ -32,7 +32,6 @@ import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo'
import { connectionObserverStore } from '@/stores/connectionObserver'
import { PictureInPictureConference } from '@/features/pip/components/PictureInPictureConference'
export const Conference = ({
roomId,
@@ -292,7 +291,6 @@ export const Conference = ({
{...mediaDeviceError}
onClose={() => setMediaDeviceError({ error: null, kind: null })}
/>
<PictureInPictureConference />
</LiveKitRoom>
</Screen>
</QueryAware>
@@ -9,8 +9,7 @@ import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -39,8 +38,6 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
return (
<Div
display="flex"
@@ -133,17 +130,6 @@ export const Admin = () => {
fullWidth: true,
}}
/>
<Field
type="switch"
label={t('moderation.mute.label')}
description={t('moderation.mute.description')}
isSelected={isMutingEnabled}
onChange={toggleMuting}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
</div>
</div>
<div
@@ -7,7 +7,6 @@ import { EffectsMenuItem } from './EffectsMenuItem'
import { SupportMenuItem } from './SupportMenuItem'
import { TranscriptMenuItem } from './TranscriptMenuItem'
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
// @todo try refactoring it to use MenuList component
export const OptionsMenuItems = () => {
@@ -19,7 +18,6 @@ export const OptionsMenuItems = () => {
}}
>
<MenuSection>
<PictureInPictureMenuItem />
<TranscriptMenuItem />
<ScreenRecordingMenuItem />
<FullScreenMenuItem />
@@ -1,22 +0,0 @@
import { RiPictureInPicture2Line } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
export const PictureInPictureMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { toggle, isOpen, isSupported } = usePictureInPicture()
if (!isSupported) return null
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={toggle}
>
<RiPictureInPicture2Line size={20} />
{t(`pictureInPicture.${isOpen ? 'exit' : 'enter'}`)}
</MenuItem>
)
}
@@ -1,13 +1,7 @@
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
import { Participant } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useCanMute = (participant: Participant) => {
const apiRoomData = useRoomData()
const isAdminOrOwner = useIsAdminOrOwner()
return (
participant.isLocal ||
isAdminOrOwner ||
apiRoomData?.configuration?.everyone_can_mute !== false
)
return participant.isLocal || isAdminOrOwner
}
@@ -1,46 +0,0 @@
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useCallback } from 'react'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
export const usePermissionsManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const data = useRoomData()
const configuration = data?.configuration
const roomId = data?.slug
const isMutingEnabled = configuration?.everyone_can_mute ?? true
const toggleMuting = useCallback(
async (enabled: boolean) => {
if (!roomId) return
try {
const newConfiguration = {
...configuration,
everyone_can_mute: enabled,
}
const room = await patchRoom({
roomId,
room: { configuration: newConfiguration },
})
queryClient.setQueryData([keys.room, roomId], room)
return { configuration: newConfiguration }
} catch (error) {
console.error('Failed to update muting permission:', error)
return { success: false, error }
}
},
[configuration, roomId, patchRoom]
)
return {
toggleMuting,
isMutingEnabled,
}
}
@@ -39,6 +39,10 @@ export const usePublishSourcesManager = () => {
const { notifyParticipants } = useNotifyParticipants()
const defaultSources = configData?.livekit?.default_sources?.map((source) => {
return source as Source
})
// The name can be misleading—use the slug instead to ensure the correct React Query key is updated.
const roomId = data?.slug
@@ -50,16 +54,16 @@ export const usePublishSourcesManager = () => {
)
const currentSources = useMemo(() => {
const defaultSources = configData?.livekit?.default_sources ?? []
if (
configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources)
) {
return defaultSources
}
return configuration.can_publish_sources
}, [configData, configuration?.can_publish_sources])
return configuration.can_publish_sources.map((source) => {
return source as Source
})
}, [defaultSources, configuration?.can_publish_sources])
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -74,7 +78,7 @@ export const usePublishSourcesManager = () => {
const newConfiguration = {
...configuration,
can_publish_sources: newSources,
can_publish_sources: newSources as string[],
}
const room = await patchRoom({
@@ -1,86 +0,0 @@
// features/rooms/hooks/useSyncLiveKitMetadata.ts
import { useEffect } from 'react'
import { RoomEvent } from 'livekit-client'
import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import {
ApiAccessLevel,
ApiRoom,
RoomConfiguration,
} from '@/features/rooms/api/ApiRoom'
import { useRoomContext } from '@livekit/components-react'
import { useRoomData } from './useRoomData'
/**
* Shape of the LiveKit room metadata blob pushed by the backend.
* Matches RoomManagement.update_metadata {"configuration": room.configuration}
*/
type RoomLiveKitMetadata = {
configuration?: RoomConfiguration
access_level?: ApiAccessLevel
}
const parseMetadata = (raw: string | undefined): RoomLiveKitMetadata | null => {
if (!raw) return null
try {
return JSON.parse(raw) as RoomLiveKitMetadata
} catch {
console.warn('useSyncLiveKitMetadata: failed to parse room metadata')
return null
}
}
/**
* Sync LiveKit room metadata into the React Query cache.
*
* The backend pushes room configuration into LiveKit's room metadata
* whenever it changes. This hook listens for those changes and patches
* the ApiRoom cache so every `useRoomData()`
* consumer sees the fresh value automatically.
*
* Mount once, at the level where the LiveKit Room instance lives.
*/
export const useSyncLiveKitMetadata = () => {
const room = useRoomContext()
const roomData = useRoomData()
const roomSlug = roomData?.slug
useEffect(() => {
if (!room || !roomSlug) return
const applyMetadata = (raw: string | undefined) => {
const parsed = parseMetadata(raw)
if (!parsed) return
queryClient.setQueryData<ApiRoom>([keys.room, roomSlug], (prev) => {
if (!prev) return prev
const nextConfiguration = parsed.configuration ?? prev.configuration
const nextAccessLevel = parsed.access_level ?? prev.access_level
if (
nextConfiguration === prev.configuration &&
nextAccessLevel === prev.access_level
) {
return prev
}
return {
...prev,
configuration: nextConfiguration,
access_level: nextAccessLevel,
}
})
}
// Apply whatever metadata is currently set (covers the case where we
// joined the room AFTER the last metadata change, so no event will fire).
applyMetadata(room.metadata)
const handler = (raw: string) => applyMetadata(raw)
room.on(RoomEvent.RoomMetadataChanged, handler)
return () => {
room.off(RoomEvent.RoomMetadataChanged, handler)
}
}, [room, roomSlug])
}
@@ -26,7 +26,6 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
export function MobileControlBar({
onDeviceError,
@@ -63,7 +62,6 @@ export function MobileControlBar({
}
hideMenu={true}
/>
<ReactionsToggle />
<HandToggle />
<Button
id="room-options-trigger"
@@ -32,7 +32,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useSettingsDialog } from '@/features/settings'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
@@ -41,8 +40,6 @@ import { ReactionPortals } from '@/features/reactions/components/ReactionPortals
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
import { GridLayout } from '@/features/layout/components/GridLayout'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture'
import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder'
/**
* @public
@@ -93,7 +90,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useRoomPageTitle()
useVideoResolutionSubscription()
useSyncLiveKitMetadata()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
@@ -123,8 +119,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
(track) => !isEqualTrackRef(track, focusTrack)
)
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
// handle pin announcements
useEffect(() => {
@@ -254,38 +248,32 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
/>
<IsIdleDisconnectModal />
<RoomContentArea>
{isPictureInPictureOpen ? (
<PipRoomPlaceholder />
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</RoomContentArea>
<ControlBar
@@ -1,20 +1,10 @@
// Google Meet uses only letters in a room identifier
const ROOM_ID_ALLOWED_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz'
const getRandomChar = () => {
const maxValue =
Math.floor(0x100000000 / ROOM_ID_ALLOWED_CHARACTERS.length) *
ROOM_ID_ALLOWED_CHARACTERS.length
const randomValue = new Uint32Array(1)
do {
crypto.getRandomValues(randomValue)
} while (randomValue[0] >= maxValue)
return ROOM_ID_ALLOWED_CHARACTERS[
randomValue[0] % ROOM_ID_ALLOWED_CHARACTERS.length
const getRandomChar = () =>
ROOM_ID_ALLOWED_CHARACTERS[
Math.floor(Math.random() * ROOM_ID_ALLOWED_CHARACTERS.length)
]
}
const generateSegment = (length: number): string =>
Array.from(Array(length), getRandomChar).join('')
@@ -1,14 +1,9 @@
import { Field, H, Text } from '@/primitives'
import { Field, H } from '@/primitives'
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
import { css } from '@/styled-system/css'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import {
accessibilityStore,
UI_FONT_OPTIONS,
type UiFont,
} from '@/stores/accessibility'
import { accessibilityStore } from '@/stores/accessibility'
import { CaptionsSettings } from '@/features/subtitle/component/CaptionsSettings'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
@@ -17,15 +12,6 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
const { t } = useTranslation('settings')
const snap = useSnapshot(accessibilityStore)
const fontItems = useMemo(
() =>
UI_FONT_OPTIONS.map((font) => ({
value: font,
label: t(`accessibility.font.options.${font}`),
})),
[t]
)
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('tabs.accessibility')}</H>
@@ -47,21 +33,6 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
<li>
<Field
type="select"
label={t('accessibility.font.label')}
items={fontItems}
selectedKey={snap.uiFont}
onSelectionChange={(key) => {
accessibilityStore.uiFont = key as UiFont
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
<Text variant="smNote" className={css({ marginTop: '0.25rem' })}>
{t('accessibility.font.description')}
</Text>
</li>
<CaptionsSettings />
</ul>
</TabPanel>
@@ -1,38 +0,0 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { accessibilityStore, UiFont } from '@/stores/accessibility'
const fontImports: Partial<Record<UiFont, () => Promise<unknown>>> = {
lexend: () => import('@fontsource-variable/lexend'),
'atkinson-hyperlegible': () =>
import('@fontsource-variable/atkinson-hyperlegible-next'),
opendyslexic: () => import('@fontsource/opendyslexic'),
}
const loadedFonts = new Set<UiFont>()
export function useApplyA11yFonts() {
const { uiFont } = useSnapshot(accessibilityStore)
useEffect(() => {
if (uiFont === 'default') {
return
}
const className = `font-${uiFont}`
const loader = fontImports[uiFont]
if (loader && !loadedFonts.has(uiFont)) {
loader().then(() => {
loadedFonts.add(uiFont)
document.documentElement.classList.add(className)
})
} else {
document.documentElement.classList.add(className)
}
return () => {
document.documentElement.classList.remove(className)
}
}, [uiFont])
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Aufzeichnung nicht verfügbar",
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Host der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur die*der Organisator:in der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
},
"expired": {
"title": "Aufzeichnung abgelaufen",
@@ -22,7 +22,7 @@
"button": "Herunterladen",
"warning": {
"title": "Linkfreigabe deaktiviert",
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Hosts können sie herunterladen."
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisator:innen können sie herunterladen."
}
}
}

Some files were not shown because too many files have changed in this diff Show More