mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eaa3a2a284 | |||
| cdc5add2f3 | |||
| 8793226427 | |||
| 357a989b9d | |||
| b8f3c0aa59 | |||
| 37a3d6e7ed | |||
| afa9d36329 | |||
| 3d81110f56 | |||
| 5a508aeb31 | |||
| f9ca489b2f | |||
| 47ec78973b | |||
| c239a7f2e5 | |||
| 87f8d4ca7e | |||
| 45b939da5c | |||
| 5a6654b3a8 | |||
| afaaec4be1 | |||
| 184e957565 | |||
| 323a998721 | |||
| 2b50dd3158 | |||
| 852789cec6 | |||
| f38dec6a93 | |||
| d8548ec9bb | |||
| 775c8ac073 | |||
| 90ef4ce025 | |||
| 8b1d200740 | |||
| f6510b7488 | |||
| 640205fd9e | |||
| 3c04d1e9a7 | |||
| bbc3d18309 | |||
| 61877d23bc | |||
| a6d20e2f58 | |||
| 1e21274237 | |||
| 6b566831a2 | |||
| b57e11bf51 | |||
| 2378af9e9b | |||
| 30107882dd | |||
| 4f25a1d983 | |||
| 2c02a26563 | |||
| 3eb694b6dd | |||
| 9b2e7905fc | |||
| e1c6fb10f3 | |||
| 04ceb5d568 | |||
| 1771527c2d | |||
| 63a949e420 | |||
| 69135feba5 | |||
| 57a27de8e7 | |||
| 09bb4daebc | |||
| 3e3d9f31a5 | |||
| 439f7b3746 | |||
| fa6967635d | |||
| 07e172041f | |||
| 8c93d26d48 | |||
| a2c55f2305 | |||
| 2f1d102b44 | |||
| e9634b7610 | |||
| 4697cbc875 | |||
| 8c3fb63724 |
@@ -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
|
||||
|
||||
+1
-16
@@ -10,29 +10,15 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(fullstack) allow participants to mute others based on room configuration
|
||||
- ✨(frontend) add synchronizer for room metadata updates
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(fullstack) simplify source serialization
|
||||
- ✨(backend) expose room configuration to all API consumers
|
||||
|
||||
## [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
|
||||
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
|
||||
|
||||
### 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
|
||||
|
||||
@@ -42,7 +28,6 @@ and this project adheres to
|
||||
- ⬆️(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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+8
-12
@@ -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:
|
||||
|
||||
Generated
+250
-12
@@ -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",
|
||||
@@ -4319,6 +4319,44 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"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": ">= 8"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgr/core": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
|
||||
@@ -4332,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",
|
||||
@@ -6635,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",
|
||||
@@ -8010,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",
|
||||
@@ -8098,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",
|
||||
@@ -8666,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",
|
||||
@@ -10921,6 +11044,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",
|
||||
@@ -12505,6 +12638,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",
|
||||
@@ -12839,6 +12985,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",
|
||||
@@ -13325,6 +13502,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",
|
||||
@@ -13357,6 +13545,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",
|
||||
@@ -13630,13 +13842,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": {
|
||||
@@ -14048,6 +14260,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",
|
||||
@@ -15086,6 +15311,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
-1963
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -13,7 +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 BaseModel, Field
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -166,6 +166,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 +187,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,7 +317,9 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||
RoomConfigurationTrackSource = Literal[
|
||||
"camera", "microphone", "screen_share", "screen_share_audio"
|
||||
]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
@@ -321,12 +328,14 @@ class RoomConfiguration(BaseModel):
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_publish_sources: list[TrackSource] | None = None
|
||||
everyone_can_mute: bool | None = None
|
||||
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||
|
||||
|
||||
class ParticipantPermission(BaseModel):
|
||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
||||
|
||||
@@ -346,10 +355,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."""
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
@@ -77,11 +76,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
|
||||
|
||||
@@ -305,41 +299,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"],
|
||||
@@ -655,11 +614,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."""
|
||||
@@ -668,26 +623,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),
|
||||
|
||||
@@ -388,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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -114,120 +106,11 @@ def test_api_rooms_update_administrators(mock_update_metadata):
|
||||
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": [
|
||||
@@ -239,17 +122,12 @@ def test_api_rooms_update_administrators_name_only(mock_update_metadata):
|
||||
},
|
||||
{"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):
|
||||
def test_api_rooms_update_configuration_valid(configuration):
|
||||
"""Administrators should be allowed to set valid configurations."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")], configuration={})
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -262,28 +140,6 @@ def test_api_rooms_update_configuration_valid(mock_update_metadata, configuratio
|
||||
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."""
|
||||
@@ -342,24 +198,6 @@ def test_api_rooms_update_configuration_wrong_type():
|
||||
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 == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators_of_another():
|
||||
"""
|
||||
Being administrator or owner of a room should not grant authorization to update
|
||||
@@ -379,61 +217,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"]},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -121,11 +121,7 @@ def generate_token(
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_attributes(
|
||||
{
|
||||
"color": color,
|
||||
"room_admin": "true" if is_admin_or_owner else "false",
|
||||
"is_authenticated": not user.is_anonymous,
|
||||
}
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -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]
|
||||
|
||||
Generated
+4
-6
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
declare module '@react-aria/overlays' {
|
||||
export type PortalProviderContextValue = {
|
||||
getContainer: () => HTMLElement | null;
|
||||
};
|
||||
|
||||
export type PortalProviderProps = {
|
||||
getContainer: () => HTMLElement | null;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function useUNSAFE_PortalContext(): PortalProviderContextValue;
|
||||
export function UNSAFE_PortalProvider(
|
||||
props: PortalProviderProps,
|
||||
): JSX.Element;
|
||||
}
|
||||
|
||||
Generated
+465
-424
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -47,7 +47,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 +62,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.10",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 9.7 KiB |
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
|
||||
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
|
||||
@@ -16,6 +16,10 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { Emoji } from '@/features/reactions/types'
|
||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||
|
||||
// Sliding window of recent chat ids kept for deduplication. Sized to comfortably
|
||||
// cover bursts and re-emits while staying negligible in memory.
|
||||
const MAX_TRACKED_CHAT_IDS = 16
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
@@ -24,12 +28,23 @@ export const MainNotificationToast = () => {
|
||||
|
||||
const { appendReaction } = useReactions()
|
||||
|
||||
// Multiple Chat instances may re-emit the same RoomEvent.ChatMessage.
|
||||
// Dedupe against a small ring of recent ids.
|
||||
const seenChatMsgIdsRef = useRef<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChatMessage = (
|
||||
chatMessage: ChatMessage,
|
||||
participant?: Participant | undefined
|
||||
) => {
|
||||
if (!participant || participant.isLocal) return
|
||||
const id = chatMessage.id
|
||||
if (id) {
|
||||
const seen = seenChatMsgIdsRef.current
|
||||
if (seen.includes(id)) return
|
||||
seen.push(id)
|
||||
if (seen.length > MAX_TRACKED_CHAT_IDS) seen.shift()
|
||||
}
|
||||
triggerNotificationSound(NotificationType.MessageReceived)
|
||||
toastQueue.add(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocumentPiP } from '../hooks/useDocumentPiP'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { UNSAFE_PortalProvider as PortalProvider } from '@react-aria/overlays'
|
||||
import { VisualOnlyTooltipsContext } from '@/primitives/VisualOnlyTooltipsContext'
|
||||
|
||||
// Minimal base styles so the PiP window renders correctly on first paint.
|
||||
const ensureBaseStyles = (target: Document) => {
|
||||
if (target.getElementById('pip-base-styles')) return
|
||||
const style = target.createElement('style')
|
||||
style.id = 'pip-base-styles'
|
||||
style.textContent = `
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #0b0f19; }
|
||||
body { overflow: hidden; }
|
||||
* { box-sizing: border-box; }
|
||||
`
|
||||
target.head.appendChild(style)
|
||||
}
|
||||
|
||||
// Clone existing styles to keep the PiP window visually consistent.
|
||||
const copyStyles = (source: Document, target: Document) => {
|
||||
if (target.getElementById('pip-style-clone')) return
|
||||
const marker = target.createElement('meta')
|
||||
marker.id = 'pip-style-clone'
|
||||
target.head.appendChild(marker)
|
||||
|
||||
source.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
|
||||
const cloned = node.cloneNode(true) as HTMLElement
|
||||
target.head.appendChild(cloned)
|
||||
})
|
||||
}
|
||||
|
||||
const syncThemeAttribute = (source: Document, target: Document) => {
|
||||
const theme = source.documentElement.dataset.lkTheme
|
||||
if (theme) {
|
||||
target.documentElement.dataset.lkTheme = theme
|
||||
} else {
|
||||
delete target.documentElement.dataset.lkTheme
|
||||
}
|
||||
}
|
||||
|
||||
const cssVarNameCacheByElement = new WeakMap<HTMLElement, string[]>()
|
||||
const cssVarNameCacheByUri = new Map<string, string[]>()
|
||||
|
||||
const syncCssVariables = (source: Document, target: Document) => {
|
||||
const sourceView = source.defaultView
|
||||
if (!sourceView) return
|
||||
|
||||
const getCachedVarNames = () => {
|
||||
const docEl = source.documentElement
|
||||
if (!docEl) return []
|
||||
|
||||
const cachedByElement = cssVarNameCacheByElement.get(docEl)
|
||||
if (cachedByElement) return cachedByElement
|
||||
|
||||
const cachedByUri = source.baseURI
|
||||
? cssVarNameCacheByUri.get(source.baseURI)
|
||||
: undefined
|
||||
if (cachedByUri) return cachedByUri
|
||||
|
||||
const varNames = new Set<string>()
|
||||
const collectVarsFrom = (element: HTMLElement | null) => {
|
||||
if (!element) return
|
||||
const styles = sourceView.getComputedStyle(element)
|
||||
for (const property of Array.from(styles)) {
|
||||
if (property.startsWith('--')) {
|
||||
varNames.add(property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectVarsFrom(source.documentElement)
|
||||
collectVarsFrom(source.body)
|
||||
|
||||
const result = Array.from(varNames)
|
||||
cssVarNameCacheByElement.set(docEl, result)
|
||||
if (source.baseURI) {
|
||||
cssVarNameCacheByUri.set(source.baseURI, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const varNames = getCachedVarNames()
|
||||
if (!varNames.length) return
|
||||
|
||||
const rootStyles = sourceView.getComputedStyle(source.documentElement)
|
||||
const bodyStyles = source.body
|
||||
? sourceView.getComputedStyle(source.body)
|
||||
: null
|
||||
|
||||
varNames.forEach((property) => {
|
||||
const bodyValue = bodyStyles?.getPropertyValue(property)
|
||||
const value = bodyValue || rootStyles.getPropertyValue(property)
|
||||
if (value) {
|
||||
target.documentElement.style.setProperty(property, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* React portal into a Document Picture-in-Picture window. Handles window
|
||||
* lifecycle, style/theme sync and routes React Aria overlays via
|
||||
* `UNSAFE_PortalProvider` so they render inside the PiP document.
|
||||
*/
|
||||
export const DocumentPiPPortal = ({
|
||||
isOpen,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
children: React.ReactNode
|
||||
onClose?: () => void
|
||||
}): ReactNode => {
|
||||
const { openPiP, closePiP, pipWindow, isSupported } = useDocumentPiP({
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||
const containerRef = useRef<HTMLElement | null>(null)
|
||||
const prevOpenRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
closePiP()
|
||||
setContainer(null)
|
||||
containerRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupported) return
|
||||
|
||||
let cancelled = false
|
||||
openPiP().then((win) => {
|
||||
if (!win || cancelled) return
|
||||
const doc = win.document
|
||||
ensureBaseStyles(doc)
|
||||
copyStyles(document, doc)
|
||||
syncThemeAttribute(document, doc)
|
||||
syncCssVariables(document, doc)
|
||||
|
||||
doc.documentElement.setAttribute('lang', document.documentElement.lang)
|
||||
doc.title = t('windowLabel')
|
||||
|
||||
const existingContainer = containerRef.current
|
||||
if (!existingContainer || existingContainer.ownerDocument !== doc) {
|
||||
const nextContainer = doc.createElement('div')
|
||||
nextContainer.id = 'pip-root'
|
||||
nextContainer.style.width = '100%'
|
||||
nextContainer.style.height = '100%'
|
||||
nextContainer.style.display = 'flex'
|
||||
nextContainer.style.alignItems = 'stretch'
|
||||
nextContainer.style.justifyContent = 'center'
|
||||
doc.body.appendChild(nextContainer)
|
||||
containerRef.current = nextContainer
|
||||
setContainer(nextContainer)
|
||||
} else {
|
||||
setContainer(existingContainer)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [closePiP, isOpen, isSupported, openPiP, t])
|
||||
|
||||
// Focus stays on the trigger; PiP is announced as an auxiliary surface.
|
||||
useEffect(() => {
|
||||
const wasOpen = prevOpenRef.current
|
||||
prevOpenRef.current = isOpen
|
||||
|
||||
if (isOpen && !wasOpen) {
|
||||
announce(t('opened'), 'polite')
|
||||
}
|
||||
if (!isOpen && wasOpen) {
|
||||
announce(t('closed'), 'polite')
|
||||
}
|
||||
}, [isOpen, announce, t])
|
||||
|
||||
useRestoreFocus(isOpen, { restoreFocusRaf: true })
|
||||
|
||||
// Escape from either document closes PiP (unless a nested overlay handled it).
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
event.preventDefault()
|
||||
onClose?.()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
pipWindow?.document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
pipWindow?.document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [isOpen, onClose, pipWindow])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipWindow) return
|
||||
const handleClose = () => {
|
||||
containerRef.current = null
|
||||
setContainer(null)
|
||||
onClose?.()
|
||||
}
|
||||
pipWindow.addEventListener('pagehide', handleClose)
|
||||
pipWindow.addEventListener('beforeunload', handleClose)
|
||||
return () => {
|
||||
pipWindow.removeEventListener('pagehide', handleClose)
|
||||
pipWindow.removeEventListener('beforeunload', handleClose)
|
||||
}
|
||||
}, [onClose, pipWindow])
|
||||
|
||||
const portal = useMemo(() => {
|
||||
if (!container) return null
|
||||
return createPortal(
|
||||
<PortalProvider getContainer={() => container}>
|
||||
<VisualOnlyTooltipsContext.Provider value={true}>
|
||||
{children}
|
||||
</VisualOnlyTooltipsContext.Provider>
|
||||
</PortalProvider>,
|
||||
container
|
||||
)
|
||||
}, [children, container])
|
||||
|
||||
return portal as unknown as ReactNode
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRef, useMemo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { findFirstFocusable } from '@/utils/dom'
|
||||
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 { usePipElementSize } from '../hooks/usePipElementSize'
|
||||
import { PipOptionsMenu } from './controls/PipOptionsMenu'
|
||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||
|
||||
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: 'options.items.pictureInPicture',
|
||||
})
|
||||
|
||||
const hidden = useMemo(
|
||||
() => getHiddenControls(width, showScreenShare),
|
||||
[width, showScreenShare]
|
||||
)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
handler: useCallback(() => {
|
||||
const doc = containerRef.current?.ownerDocument ?? document
|
||||
findFirstFocusable(doc.getElementById('pip-control-bar'))?.focus()
|
||||
}, []),
|
||||
})
|
||||
|
||||
return (
|
||||
<PipControls
|
||||
ref={containerRef}
|
||||
id="pip-control-bar"
|
||||
role="toolbar"
|
||||
aria-label={t('controlBar')}
|
||||
>
|
||||
<PipControlsCenter>
|
||||
<AudioDevicesControl hideMenu />
|
||||
<VideoDeviceControl hideMenu />
|
||||
{!hidden.has(CollapsibleControls.REACTIONS) && (
|
||||
<ReactionsToggle id="pip-reactions-toggle" />
|
||||
)}
|
||||
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
|
||||
<ScreenShareToggle />
|
||||
)}
|
||||
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
|
||||
<PipOptionsMenu overflowControls={hidden} />
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</PipControlsCenter>
|
||||
</PipControls>
|
||||
)
|
||||
}
|
||||
|
||||
const PipControls = styled('div', {
|
||||
base: {
|
||||
flex: '0 0 auto',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.5rem 0.75rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
},
|
||||
})
|
||||
|
||||
const PipControlsCenter = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '0.4rem',
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRoomPiP } from '../hooks/useRoomPiP'
|
||||
|
||||
export const PipPlaceholder = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture.placeholder',
|
||||
})
|
||||
const { close } = useRoomPiP()
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Illustration
|
||||
src="/assets/pip.svg"
|
||||
alt=""
|
||||
width={102}
|
||||
height={72}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Title>{t('title')}</Title>
|
||||
<Description>{t('description')}</Description>
|
||||
<BringBackLink onClick={close}>{t('bringBack')}</BringBackLink>
|
||||
</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',
|
||||
},
|
||||
})
|
||||
|
||||
const Illustration = styled('img', {
|
||||
base: {
|
||||
marginBottom: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const Title = styled('p', {
|
||||
base: {
|
||||
color: 'white',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const Description = styled('p', {
|
||||
base: {
|
||||
color: '#DFE2EA',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.4,
|
||||
margin: 0,
|
||||
maxWidth: '312px',
|
||||
},
|
||||
})
|
||||
|
||||
const BringBackLink = styled('button', {
|
||||
base: {
|
||||
color: '#A2B6FF',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.4,
|
||||
cursor: 'pointer',
|
||||
marginTop: '0.25rem',
|
||||
borderRadius: '2px',
|
||||
_hover: {
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
_focusVisible: {
|
||||
outline: '2px solid #A2B6FF',
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { reactionsStore } from '@/stores/reactions'
|
||||
import { FloatingReaction } from '@/features/reactions/components/ReactionPortals'
|
||||
import type { Reaction } from '@/features/reactions/types'
|
||||
|
||||
/**
|
||||
* Renders floating emoji reactions inside the PiP window.
|
||||
* Reads the same shared reactionsStore used by the main window.
|
||||
*/
|
||||
export const PipReactionPortals = () => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { SidePanel } from '@/features/rooms/livekit/components/SidePanel'
|
||||
import {
|
||||
SidePanelStoreProvider,
|
||||
useSidePanel,
|
||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { ReactionsToolbarStoreProvider } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||
import { useEscapeDismiss } from '../hooks/useEscapeDismiss'
|
||||
import { usePipKeyboardShortcuts } from '../hooks/usePipKeyboardShortcuts'
|
||||
import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
|
||||
import { usePipFocusModality } from '../hooks/usePipFocusModality'
|
||||
import { PipControlBar } from './PipControlBar'
|
||||
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
|
||||
import { PipStage } from './layouts/PipStage'
|
||||
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
|
||||
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
|
||||
import { PipReactionPortals } from './PipReactionPortals'
|
||||
|
||||
export const PipView = () => {
|
||||
return (
|
||||
<SidePanelStoreProvider value={pipLayoutStore}>
|
||||
<ReactionsToolbarStoreProvider value={pipLayoutStore}>
|
||||
<PipViewContent />
|
||||
</ReactionsToolbarStoreProvider>
|
||||
</SidePanelStoreProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const PipViewContent = () => {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { isSidePanelOpen, closePanel } = useSidePanel()
|
||||
|
||||
// Escape closes the side panel instead of the whole PiP window.
|
||||
useEscapeDismiss(containerRef, isSidePanelOpen, closePanel)
|
||||
|
||||
// Forward keyboard shortcuts (Ctrl+D, Ctrl+E, etc.) to the main store.
|
||||
usePipKeyboardShortcuts(containerRef)
|
||||
|
||||
// Sync React Aria's focus-visible modality with the PiP document.
|
||||
usePipFocusModality(containerRef)
|
||||
|
||||
// Side panels open via a menu item that unmounts on click; fall back to the
|
||||
// options button so focus returns somewhere visible.
|
||||
const resolveTrigger = useCallback((activeEl: HTMLElement | null) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
const doc = containerRef.current?.ownerDocument ?? document
|
||||
return doc.getElementById('room-options-trigger')
|
||||
}
|
||||
return activeEl
|
||||
}, [])
|
||||
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
|
||||
|
||||
return (
|
||||
<PipContainer
|
||||
ref={containerRef}
|
||||
role="region"
|
||||
aria-label={t('windowLabel')}
|
||||
>
|
||||
<PipStage />
|
||||
<ReactionsToolbar
|
||||
toggleId="pip-reactions-toggle"
|
||||
controlBarId="pip-control-bar"
|
||||
/>
|
||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||
<SidePanel />
|
||||
<PipReactionPortals />
|
||||
<OverlayStack>
|
||||
<PipConnectionStateToast />
|
||||
<PipNotificationOverlay />
|
||||
</OverlayStack>
|
||||
</PipContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const OverlayStack = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
left: '0.5rem',
|
||||
right: '0.5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '0.375rem',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1000,
|
||||
'& > *': { pointerEvents: 'auto' },
|
||||
},
|
||||
})
|
||||
|
||||
const PipContainer = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'minmax(0, 1fr) auto auto',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
// 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%',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
|
||||
import { roomPiPStore } from '@/stores/roomPiP'
|
||||
import { DocumentPiPPortal } from './DocumentPiPPortal'
|
||||
import { PipView } from './PipView'
|
||||
import { useRoomPiP } from '../hooks/useRoomPiP'
|
||||
|
||||
/**
|
||||
* Wrapper that mounts the PiP UI when room-level PiP state is enabled.
|
||||
* Bridges Valtio-backed PiP state with DocumentPiPPortal and PipView rendering.
|
||||
* PiP panel state is decoupled via explicit pipLayoutStore injection.
|
||||
*/
|
||||
export const RoomPiP = (): ReactNode => {
|
||||
const { isOpen, close } = useRoomPiP()
|
||||
|
||||
// Reset PiP state on unmount (e.g. leaving the room) so the next session
|
||||
// starts with PiP closed and doesn't try to auto-reopen without a user gesture.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
roomPiPStore.isOpen = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const portal = DocumentPiPPortal({
|
||||
isOpen,
|
||||
onClose: close,
|
||||
children: <PipView />,
|
||||
})
|
||||
return portal
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
|
||||
import type { CollapsibleControl } from '../PipControlBar'
|
||||
|
||||
type PipOptionsMenuProps = {
|
||||
overflowControls?: Set<CollapsibleControl>
|
||||
}
|
||||
|
||||
/**
|
||||
* PiP-native options menu. The shared `Menu` primitive mis-positions its
|
||||
* popover and loses focus across documents, so we drive open/close, focus
|
||||
* and dismissal ourselves.
|
||||
*/
|
||||
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')
|
||||
|
||||
useEscapeDismiss(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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Menu as RACMenu, MenuSection } from 'react-aria-components'
|
||||
import { Separator } from '@/primitives/Separator'
|
||||
import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem'
|
||||
import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
|
||||
import { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem'
|
||||
import { PictureInPictureMenuItem } from '@/features/rooms/livekit/components/controls/Options/PictureInPictureMenuItem'
|
||||
import { PipOverflowItems } from './PipOverflowItems'
|
||||
import type { CollapsibleControl } from '../PipControlBar'
|
||||
|
||||
type PipOptionsMenuItemsProps = {
|
||||
overflowControls?: Set<CollapsibleControl>
|
||||
}
|
||||
|
||||
export const PipOptionsMenuItems = ({
|
||||
overflowControls,
|
||||
}: PipOptionsMenuItemsProps) => {
|
||||
const hasOverflow = (overflowControls?.size ?? 0) > 0
|
||||
|
||||
const hasOverflowControls = hasOverflow && overflowControls
|
||||
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '150px',
|
||||
width: '300px',
|
||||
}}
|
||||
>
|
||||
{hasOverflowControls && (
|
||||
<>
|
||||
<MenuSection>
|
||||
<PipOverflowItems overflowControls={overflowControls} />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
<MenuSection>
|
||||
<PictureInPictureMenuItem />
|
||||
<EffectsMenuItem />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
<MenuSection>
|
||||
<SupportMenuItem />
|
||||
<FeedbackMenuItem />
|
||||
</MenuSection>
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { RiHand, RiArrowUpLine, RiEmotionLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Track } from 'livekit-client'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useRoomContext, useTrackToggle } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { CollapsibleControls, type CollapsibleControl } from '../PipControlBar'
|
||||
|
||||
type PipOverflowItemsProps = {
|
||||
overflowControls: Set<CollapsibleControl>
|
||||
}
|
||||
|
||||
export const PipOverflowItems = ({
|
||||
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 (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
<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,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
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 { usePipFlipAnimations } from '../../hooks/usePipFlipAnimations'
|
||||
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 tileKeys = useMemo(() => tracks.map(getTrackKey), [tracks])
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
usePipFlipAnimations(containerRef, tileKeys)
|
||||
|
||||
return (
|
||||
<GridContainer ref={containerRef} style={gridStyle}>
|
||||
{tracks.map((track, index) => (
|
||||
<GridCell key={tileKeys[index]} 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%',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTracks } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import {
|
||||
isCameraTrack,
|
||||
pickLocalCameraTrack,
|
||||
pickRemoteCameraTrack,
|
||||
pickScreenShareTrack,
|
||||
} from '../../utils/pipTrackSelection'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
|
||||
/**
|
||||
* Above this count the PiP stage switches from the focus layout
|
||||
* (main + thumbnail) to the adaptive grid layout.
|
||||
*/
|
||||
const FOCUS_MAX_TILES = 2
|
||||
|
||||
// Handles which layout to render inside the PiP stage.
|
||||
|
||||
export const PipStage = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: false }
|
||||
)
|
||||
|
||||
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
|
||||
|
||||
// Order the list so the "focus target" (screen share when available,
|
||||
// otherwise a remote camera) is first. Both layouts consume this order.
|
||||
const stageTracks = useMemo(() => {
|
||||
const cameraTracks = tracks.filter(isCameraTrack)
|
||||
if (!screenShareTrack) return cameraTracks
|
||||
return [screenShareTrack, ...cameraTracks]
|
||||
}, [tracks, screenShareTrack])
|
||||
|
||||
// avoid tabbing to the stage when it's not visible
|
||||
const frameRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
frameRef.current?.setAttribute('inert', '')
|
||||
}, [])
|
||||
|
||||
if (stageTracks.length === 0) return null
|
||||
|
||||
const stageLabel = t('stage')
|
||||
|
||||
if (stageTracks.length > FOCUS_MAX_TILES) {
|
||||
return (
|
||||
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||
<PipGridLayout tracks={stageTracks} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const localCameraTrack = pickLocalCameraTrack(stageTracks)
|
||||
const remoteCameraTrack = pickRemoteCameraTrack(stageTracks)
|
||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? stageTracks[0]
|
||||
const thumbnailTrack =
|
||||
localCameraTrack && localCameraTrack !== mainTrack
|
||||
? localCameraTrack
|
||||
: stageTracks.find((track) => track !== mainTrack)
|
||||
|
||||
return (
|
||||
<StageFrame ref={frameRef} role="region" aria-label={stageLabel}>
|
||||
<PipFocusLayout mainTrack={mainTrack} thumbnailTrack={thumbnailTrack} />
|
||||
</StageFrame>
|
||||
)
|
||||
}
|
||||
|
||||
const StageFrame = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
marginLeft: '0.5rem',
|
||||
marginRight: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||
import { ConnectionState } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
/**
|
||||
* Banner surfaced inside the PiP when the room connection degrades.
|
||||
*
|
||||
* Scoped to `Reconnecting` / `Disconnected` - the two states the user needs
|
||||
* to see while their attention is on the PiP rather than the main window.
|
||||
*/
|
||||
export const PipConnectionStateToast = () => {
|
||||
const room = useRoomContext()
|
||||
const state = useConnectionState(room)
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture.connection',
|
||||
})
|
||||
|
||||
const connectionLabels: Partial<Record<ConnectionState, string>> = {
|
||||
[ConnectionState.Reconnecting]: t('reconnecting'),
|
||||
[ConnectionState.Disconnected]: t('disconnected'),
|
||||
}
|
||||
const label = connectionLabels[state] ?? null
|
||||
|
||||
if (!label) return null
|
||||
|
||||
return <Banner role="status">{label}</Banner>
|
||||
}
|
||||
|
||||
const Banner = styled('div', {
|
||||
base: {
|
||||
backgroundColor: 'greyscale.800',
|
||||
color: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.3,
|
||||
padding: '0.375rem 0.75rem',
|
||||
borderRadius: '6px',
|
||||
boxShadow:
|
||||
'rgba(0, 0, 0, 0.4) 0px 2px 6px 0px, rgba(0, 0, 0, 0.25) 0px 4px 12px 2px',
|
||||
animation: 'fade 200ms',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useToastQueue } from '@react-stately/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
toastQueue,
|
||||
type ToastData,
|
||||
} from '@/features/notifications/components/ToastProvider'
|
||||
import { StyledToastContainer } from '@/features/notifications/components/Toast'
|
||||
import { PipToastBody } from './PipToastBody'
|
||||
|
||||
/**
|
||||
* Shows shared toasts in the PiP window.
|
||||
* We use a local aria-live region so screen readers can read them in PiP.
|
||||
*/
|
||||
const MAX_VISIBLE = 3
|
||||
|
||||
export const PipNotificationOverlay = () => {
|
||||
const state = useToastQueue<ToastData>(toastQueue)
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
})
|
||||
|
||||
if (state.visibleToasts.length === 0) return null
|
||||
|
||||
const toasts = state.visibleToasts.slice(0, MAX_VISIBLE)
|
||||
|
||||
return (
|
||||
<Region
|
||||
role="region"
|
||||
aria-label={t('notificationsLabel')}
|
||||
aria-live="polite"
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<StyledToastContainer
|
||||
key={toast.key}
|
||||
aria-atomic="true"
|
||||
css={{
|
||||
margin: 0,
|
||||
marginLeft: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingRight: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<PipToastBody toast={toast} />
|
||||
<Button
|
||||
square
|
||||
size="sm"
|
||||
invisible
|
||||
aria-label={t('dismissNotification')}
|
||||
onPress={() => state.close(toast.key)}
|
||||
>
|
||||
<RiCloseLine size={16} color="white" aria-hidden="true" />
|
||||
</Button>
|
||||
</StyledToastContainer>
|
||||
))}
|
||||
</Region>
|
||||
)
|
||||
}
|
||||
|
||||
const Region = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.375rem',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { QueuedToast } from '@react-stately/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiHand, RiMessage2Line } from '@remixicon/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import type { ToastData } from '@/features/notifications/components/ToastProvider'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
type Props = {
|
||||
toast: QueuedToast<ToastData>
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the toast content used in PiP.
|
||||
* PiP stays display-only, so main-window actions are not shown here.
|
||||
*/
|
||||
export const PipToastBody = ({ toast }: Props) => {
|
||||
const { t } = useTranslation('notifications')
|
||||
const { type, participant, message, removedSources } = toast.content
|
||||
const name = participant?.name || t('defaultName')
|
||||
|
||||
switch (type) {
|
||||
case NotificationType.ParticipantJoined:
|
||||
return <Line>{t('joined.description', { name })}</Line>
|
||||
|
||||
case NotificationType.ParticipantMuted:
|
||||
return <Line>{t('muted', { name })}</Line>
|
||||
|
||||
case NotificationType.HandRaised:
|
||||
return (
|
||||
<Line>
|
||||
<RiHand
|
||||
size={16}
|
||||
color="white"
|
||||
className={iconStyle}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('raised.description', { name })}
|
||||
</Line>
|
||||
)
|
||||
|
||||
case NotificationType.MessageReceived:
|
||||
return (
|
||||
<Line>
|
||||
<RiMessage2Line
|
||||
size={16}
|
||||
color="white"
|
||||
className={iconStyle}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<strong>{name}</strong>
|
||||
{message ? ` - ${message}` : null}
|
||||
</span>
|
||||
</Line>
|
||||
)
|
||||
|
||||
case NotificationType.TranscriptionStarted:
|
||||
return <Line>{t('transcript.started', { name })}</Line>
|
||||
case NotificationType.TranscriptionStopped:
|
||||
return <Line>{t('transcript.stopped', { name })}</Line>
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
return <Line>{t('transcript.limitReached')}</Line>
|
||||
case NotificationType.TranscriptionRequested:
|
||||
return <Line>{t('transcript.requested', { name })}</Line>
|
||||
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return <Line>{t('screenRecording.started', { name })}</Line>
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return <Line>{t('screenRecording.stopped', { name })}</Line>
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
return <Line>{t('screenRecording.limitReached')}</Line>
|
||||
case NotificationType.ScreenRecordingRequested:
|
||||
return <Line>{t('screenRecording.requested', { name })}</Line>
|
||||
|
||||
case NotificationType.RecordingSaving: {
|
||||
const mode = toast.content.mode as RecordingMode | undefined
|
||||
const key =
|
||||
mode === RecordingMode.ScreenRecording
|
||||
? 'recordingSave.screenRecording.default'
|
||||
: 'recordingSave.transcript.default'
|
||||
return <Line>{t(key)}</Line>
|
||||
}
|
||||
|
||||
case NotificationType.PermissionsRemoved: {
|
||||
const key = resolvePermissionsKey(removedSources)
|
||||
if (!key) return null
|
||||
return <Line>{t(`permissionsRemoved.${key}`)}</Line>
|
||||
}
|
||||
|
||||
default:
|
||||
return message ? <Line>{message}</Line> : null
|
||||
}
|
||||
}
|
||||
|
||||
const resolvePermissionsKey = (sources: unknown): string | null => {
|
||||
if (!Array.isArray(sources) || sources.length === 0) return null
|
||||
if (sources.length === 1) return sources[0] as string
|
||||
if (sources.includes('screen_share')) return 'screen_share'
|
||||
return null
|
||||
}
|
||||
|
||||
const Line = ({ children }: { children: ReactNode }) => (
|
||||
<HStack
|
||||
alignItems="center"
|
||||
gap="0.5rem"
|
||||
padding="0.625rem 0.75rem"
|
||||
className={css({
|
||||
fontSize: '0.8125rem',
|
||||
lineHeight: 1.3,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</HStack>
|
||||
)
|
||||
|
||||
const iconStyle = css({ flexShrink: 0 })
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
type DocumentPictureInPicture = {
|
||||
requestWindow: (options?: {
|
||||
width?: number
|
||||
height?: number
|
||||
}) => Promise<Window>
|
||||
}
|
||||
|
||||
type WindowWithDocumentPiP = Window & {
|
||||
documentPictureInPicture?: DocumentPictureInPicture
|
||||
}
|
||||
|
||||
export const useDocumentPiP = ({
|
||||
width = 400,
|
||||
height = 480,
|
||||
}: {
|
||||
width?: number
|
||||
height?: number
|
||||
} = {}) => {
|
||||
const [pipWindow, setPipWindow] = useState<Window | null>(null)
|
||||
const pipWindowRef = useRef<Window | null>(null)
|
||||
const pendingPiPRef = useRef<Promise<Window | null> | null>(null)
|
||||
|
||||
const [isSupported] = useState(() => {
|
||||
if (typeof globalThis === 'undefined') return false
|
||||
return 'documentPictureInPicture' in globalThis
|
||||
})
|
||||
|
||||
const openPiP = useCallback(async () => {
|
||||
if (!isSupported) return null
|
||||
const existingWindow = pipWindowRef.current
|
||||
if (existingWindow && !existingWindow.closed) return existingWindow
|
||||
|
||||
if (pendingPiPRef.current) return pendingPiPRef.current
|
||||
|
||||
// Request a new PiP window from the browser API.
|
||||
const pip = (globalThis as unknown as WindowWithDocumentPiP)
|
||||
.documentPictureInPicture
|
||||
if (!pip) return null
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const win = await pip.requestWindow({ width, height })
|
||||
const currentWindow = pipWindowRef.current
|
||||
if (currentWindow && !currentWindow.closed) return currentWindow
|
||||
setPipWindow(win)
|
||||
return win
|
||||
} 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
|
||||
} finally {
|
||||
pendingPiPRef.current = null
|
||||
}
|
||||
})()
|
||||
|
||||
pendingPiPRef.current = requestPromise
|
||||
return requestPromise
|
||||
}, [height, isSupported, width])
|
||||
|
||||
const closePiP = useCallback(() => {
|
||||
if (!pipWindow) return
|
||||
if (!pipWindow.closed) {
|
||||
pipWindow.close()
|
||||
}
|
||||
setPipWindow(null)
|
||||
}, [pipWindow])
|
||||
|
||||
useEffect(() => {
|
||||
pipWindowRef.current = pipWindow
|
||||
}, [pipWindow])
|
||||
|
||||
// Force-close the native PiP window when the hook unmounts (e.g. the user
|
||||
// hangs up and the room is navigated away before `closePiP` could run).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const win = pipWindowRef.current
|
||||
if (win && !win.closed) win.close()
|
||||
pipWindowRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pipWindow) return
|
||||
|
||||
const handleClose = () => {
|
||||
setPipWindow(null)
|
||||
}
|
||||
|
||||
pipWindow.addEventListener('pagehide', handleClose)
|
||||
pipWindow.addEventListener('beforeunload', handleClose)
|
||||
|
||||
return () => {
|
||||
pipWindow.removeEventListener('pagehide', handleClose)
|
||||
pipWindow.removeEventListener('beforeunload', handleClose)
|
||||
}
|
||||
}, [pipWindow])
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isOpen: !!pipWindow && !pipWindow.closed,
|
||||
pipWindow,
|
||||
openPiP,
|
||||
closePiP,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
export const useEscapeDismiss = (
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
type Options = {
|
||||
/** Animation duration in ms. */
|
||||
duration?: number
|
||||
/** CSS easing function. */
|
||||
easing?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* FLIP (First, Last, Invert, Play) animation hook.
|
||||
*
|
||||
* For every keyed direct child of `containerRef`, records its position
|
||||
* before a render (the "first" rect) and, once the DOM has committed, plays
|
||||
* an inverse transform back to the identity position. The effect is a
|
||||
* smooth slide whenever tiles are added, removed, reordered, or a new grid
|
||||
* shape shifts them.
|
||||
*
|
||||
* Safe to call inside a Document PiP window: uses the element's own
|
||||
* Web Animations API (element.animate) which lives in the PiP document.
|
||||
* Respects `prefers-reduced-motion` and no-ops on the first mount.
|
||||
*/
|
||||
export const usePipFlipAnimations = <T extends HTMLElement>(
|
||||
containerRef: RefObject<T | null>,
|
||||
keys: ReadonlyArray<string>,
|
||||
{ duration = 220, easing = 'cubic-bezier(0.2, 0, 0, 1)' }: Options = {}
|
||||
) => {
|
||||
const prevRectsRef = useRef<Map<string, DOMRect>>(new Map())
|
||||
const firstRunRef = useRef(true)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const doc = container.ownerDocument
|
||||
const view = doc.defaultView
|
||||
const reduceMotion = view?.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)'
|
||||
).matches
|
||||
|
||||
const children = Array.from(container.children) as HTMLElement[]
|
||||
const nextRects = new Map<string, DOMRect>()
|
||||
children.forEach((el, i) => {
|
||||
const key = keys[i]
|
||||
if (!key) return
|
||||
nextRects.set(key, el.getBoundingClientRect())
|
||||
})
|
||||
|
||||
if (firstRunRef.current) {
|
||||
firstRunRef.current = false
|
||||
prevRectsRef.current = nextRects
|
||||
return
|
||||
}
|
||||
|
||||
if (!reduceMotion) {
|
||||
children.forEach((el, i) => {
|
||||
const key = keys[i]
|
||||
if (!key) return
|
||||
const prev = prevRectsRef.current.get(key)
|
||||
const next = nextRects.get(key)
|
||||
if (!prev || !next) return
|
||||
|
||||
const dx = prev.left - next.left
|
||||
const dy = prev.top - next.top
|
||||
const sx = next.width === 0 ? 1 : prev.width / next.width
|
||||
const sy = next.height === 0 ? 1 : prev.height / next.height
|
||||
|
||||
// Skip no-ops: sub-pixel shifts don't benefit from animation.
|
||||
if (
|
||||
Math.abs(dx) < 1 &&
|
||||
Math.abs(dy) < 1 &&
|
||||
Math.abs(sx - 1) < 0.01 &&
|
||||
Math.abs(sy - 1) < 0.01
|
||||
)
|
||||
return
|
||||
|
||||
el.animate(
|
||||
[
|
||||
{
|
||||
transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`,
|
||||
},
|
||||
{ transform: 'translate(0, 0) scale(1, 1)' },
|
||||
],
|
||||
{ duration, easing, fill: 'backwards' }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
prevRectsRef.current = nextRects
|
||||
}, [containerRef, duration, easing, keys])
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { setInteractionModality } from '@react-aria/interactions'
|
||||
|
||||
/**
|
||||
* Sync React Aria's interaction modality with the PiP document.
|
||||
* React Aria only installs keyboard/mouse listeners on the main document,
|
||||
* so focus-visible rings never appear in PiP without this bridge.
|
||||
*/
|
||||
export const usePipFocusModality = (
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const doc = containerRef.current?.ownerDocument
|
||||
if (!doc || doc === document) return
|
||||
|
||||
const onKeyDown = () => setInteractionModality('keyboard')
|
||||
const onMouseDown = () => setInteractionModality('pointer')
|
||||
|
||||
doc.addEventListener('keydown', onKeyDown, true)
|
||||
doc.addEventListener('mousedown', onMouseDown, true)
|
||||
return () => {
|
||||
doc.removeEventListener('keydown', onKeyDown, true)
|
||||
doc.removeEventListener('mousedown', onMouseDown, true)
|
||||
}
|
||||
}, [containerRef])
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
|
||||
/**
|
||||
* Mirror the main-window keyboard shortcuts inside the PiP document.
|
||||
*
|
||||
* The central `useKeyboardShortcuts` hook listens on `window`, which is the
|
||||
* main document's window. Keydown events from the PiP document never reach
|
||||
* it. This hook attaches the same dispatch logic to the PiP document so that
|
||||
* Ctrl+D (mic), Ctrl+E (cam), etc. work identically in both contexts.
|
||||
*/
|
||||
export const usePipKeyboardShortcuts = (
|
||||
containerRef: RefObject<HTMLElement | null>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const doc = containerRef.current?.ownerDocument
|
||||
if (!doc || doc === document) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
|
||||
if (!key) return
|
||||
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
shiftKey,
|
||||
altKey,
|
||||
})
|
||||
|
||||
let handler = keyboardShortcutsStore.shortcuts.get(shortcutKey)
|
||||
if (!handler && shortcutKey === 'ctrl+shift+?') {
|
||||
handler = keyboardShortcutsStore.shortcuts.get('ctrl+shift+/')
|
||||
}
|
||||
if (!handler) return
|
||||
|
||||
e.preventDefault()
|
||||
handler()
|
||||
}
|
||||
|
||||
doc.addEventListener('keydown', onKeyDown)
|
||||
return () => doc.removeEventListener('keydown', onKeyDown)
|
||||
}, [containerRef])
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
type Options = {
|
||||
/** Remap the captured trigger (e.g. when it unmounts on click). */
|
||||
resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null
|
||||
}
|
||||
|
||||
/**
|
||||
* `useRestoreFocus`: captures and restores focus via the PiP
|
||||
* document instead of the main one.
|
||||
*/
|
||||
export const usePipRestoreFocus = (
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
isOpen: boolean,
|
||||
{ resolveTrigger }: Options = {}
|
||||
) => {
|
||||
const prevOpenRef = useRef(false)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const doc = ref.current?.ownerDocument
|
||||
const wasOpen = prevOpenRef.current
|
||||
prevOpenRef.current = isOpen
|
||||
|
||||
if (!doc) return
|
||||
|
||||
if (!wasOpen && isOpen) {
|
||||
const activeEl = doc.activeElement as HTMLElement | null
|
||||
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||
return
|
||||
}
|
||||
|
||||
if (wasOpen && !isOpen) {
|
||||
const trigger = triggerRef.current
|
||||
triggerRef.current = null
|
||||
if (trigger && doc.contains(trigger)) {
|
||||
requestAnimationFrame(() => trigger.focus({ preventScroll: true }))
|
||||
}
|
||||
}
|
||||
}, [ref, isOpen, resolveTrigger])
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { roomPiPStore } from '@/stores/roomPiP'
|
||||
|
||||
export const useRoomPiP = () => {
|
||||
const { isOpen } = useSnapshot(roomPiPStore)
|
||||
const isSupported =
|
||||
typeof globalThis !== 'undefined' &&
|
||||
'documentPictureInPicture' in globalThis
|
||||
|
||||
const open = useCallback(() => {
|
||||
roomPiPStore.isOpen = true
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
roomPiPStore.isOpen = false
|
||||
}, [])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
roomPiPStore.isOpen = !roomPiPStore.isOpen
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { proxy } from 'valtio'
|
||||
import type { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
||||
|
||||
type PipLayoutState = {
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
showReactionsToolbar: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate layout store for the PiP window.
|
||||
* Decouples PiP side panel state from the main view so opening Chat/Info/etc.
|
||||
* in PiP does not affect the main window and vice versa.
|
||||
*/
|
||||
export const pipLayoutStore = proxy<PipLayoutState>({
|
||||
activePanelId: null,
|
||||
activeSubPanelId: null,
|
||||
showReactionsToolbar: false,
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
isTrackReference,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
|
||||
/**
|
||||
* Helpers used by the PiP layouts to classify/pick tracks.
|
||||
* Kept free of React so they are trivially testable and cheap to call.
|
||||
*/
|
||||
|
||||
export const pickScreenShareTrack = (
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined =>
|
||||
tracks
|
||||
.filter((track) => isTrackReference(track))
|
||||
.find((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
export const pickLocalCameraTrack = (
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined =>
|
||||
tracks.find(
|
||||
(track) =>
|
||||
track.source === Track.Source.Camera && track.participant?.isLocal
|
||||
)
|
||||
|
||||
export const pickRemoteCameraTrack = (
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
): TrackReferenceOrPlaceholder | undefined =>
|
||||
tracks.find(
|
||||
(track) =>
|
||||
track.source === Track.Source.Camera && !track.participant?.isLocal
|
||||
)
|
||||
|
||||
export const isCameraTrack = (track: TrackReferenceOrPlaceholder): boolean =>
|
||||
track.source === Track.Source.Camera
|
||||
|
||||
/**
|
||||
* 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`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
@@ -6,37 +6,37 @@ import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
const focusReactionsToolbar = () => {
|
||||
document
|
||||
.getElementById(REACTIONS_TOOLBAR_ID)
|
||||
?.querySelector<HTMLElement>('button')
|
||||
?.focus()
|
||||
type ReactionsToggleProps = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
export const ReactionsToggle = ({
|
||||
id = 'reactions-toggle',
|
||||
}: ReactionsToggleProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
|
||||
const { isOpen, toggle } = useReactionsToolbar()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleShortcut = useCallback(() => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
if (isOpen) {
|
||||
const doc = buttonRef.current?.ownerDocument ?? document
|
||||
doc
|
||||
.getElementById(REACTIONS_TOOLBAR_ID)
|
||||
?.querySelector<HTMLElement>('button')
|
||||
?.focus()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
toggle()
|
||||
}
|
||||
}, [])
|
||||
}, [isOpen, toggle])
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: handleShortcut,
|
||||
})
|
||||
useRegisterKeyboardShortcut({ id: 'reaction', handler: handleShortcut })
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
id="reactions-toggle"
|
||||
data-attr="reactions-toggle"
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
data-attr={id}
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFocusManager } from '@react-aria/focus'
|
||||
import { findFirstFocusable } from '@/utils/dom'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
||||
|
||||
type Props = {
|
||||
children: 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 rootRef = useRef<HTMLDivElement>(null)
|
||||
const { close } = useReactionsToolbar()
|
||||
|
||||
const onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
const fromOutside = !event.currentTarget.contains(event.relatedTarget)
|
||||
if (fromOutside) focusManager?.focusFirst()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const doc = rootRef.current?.ownerDocument ?? document
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: true })
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: true })
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
doc.getElementById(toggleId)?.focus()
|
||||
close()
|
||||
break
|
||||
case 'Tab':
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
findFirstFocusable(doc.getElementById(controlBarId))?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
id={REACTIONS_TOOLBAR_ID}
|
||||
role="toolbar"
|
||||
aria-label={t('toolbar')}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ReactionButton } from './ReactionButton'
|
||||
import {
|
||||
computeReactionsPage,
|
||||
getMaxPageStart,
|
||||
} from '../../utils/reactionsPagination'
|
||||
|
||||
type Props = { isOpen: boolean; availableWidth: number }
|
||||
|
||||
export const ReactionsPill = ({ isOpen, availableWidth }: Props) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'controls.reactions',
|
||||
})
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [pageStart, setPageStart] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsVisible(false)
|
||||
return
|
||||
}
|
||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [isOpen])
|
||||
|
||||
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight, visibleCount } =
|
||||
useMemo(
|
||||
() => computeReactionsPage(availableWidth, pageStart),
|
||||
[availableWidth, pageStart]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasOverflow) {
|
||||
setPageStart(0)
|
||||
return
|
||||
}
|
||||
const maxStart = getMaxPageStart(visibleCount)
|
||||
if (pageStart > maxStart) setPageStart(maxStart)
|
||||
}, [hasOverflow, pageStart, visibleCount])
|
||||
|
||||
const paginate = useCallback((direction: 'left' | 'right') => {
|
||||
setPageStart((current) =>
|
||||
direction === 'left' ? Math.max(0, current - 1) : current + 1
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Pill isVisible={isVisible}>
|
||||
{hasOverflow && (
|
||||
<ArrowSlot>
|
||||
<ArrowButton
|
||||
type="button"
|
||||
onClick={() => paginate('left')}
|
||||
aria-label={t('previousReactions')}
|
||||
disabled={!canGoLeft}
|
||||
>
|
||||
<RiArrowLeftSLine size={16} />
|
||||
</ArrowButton>
|
||||
</ArrowSlot>
|
||||
)}
|
||||
<EmojiRow>
|
||||
{visibleEmojis.map((emoji) => (
|
||||
<ReactionButton key={emoji} emoji={emoji} />
|
||||
))}
|
||||
</EmojiRow>
|
||||
{hasOverflow && (
|
||||
<ArrowSlot>
|
||||
<ArrowButton
|
||||
type="button"
|
||||
onClick={() => paginate('right')}
|
||||
aria-label={t('nextReactions')}
|
||||
disabled={!canGoRight}
|
||||
>
|
||||
<RiArrowRightSLine size={16} />
|
||||
</ArrowButton>
|
||||
</ArrowSlot>
|
||||
)}
|
||||
</Pill>
|
||||
)
|
||||
}
|
||||
|
||||
const Pill = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.2rem',
|
||||
borderRadius: '21px',
|
||||
padding: '0.15rem',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
width: 'fit-content',
|
||||
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 EmojiRow = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.2rem',
|
||||
'& > *': {
|
||||
flexShrink: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ArrowSlot = styled('div', {
|
||||
base: {
|
||||
width: '32px',
|
||||
minWidth: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const ArrowButton = styled('button', {
|
||||
base: {
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
backgroundColor: 'primaryDark.200',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.85,
|
||||
_hover: {
|
||||
opacity: 1,
|
||||
backgroundColor: 'primaryDark.300',
|
||||
},
|
||||
_disabled: {
|
||||
opacity: 0.3,
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,155 +1,69 @@
|
||||
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 { useEffect, useRef } from 'react'
|
||||
import { FocusScope } from '@react-aria/focus'
|
||||
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 { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePipElementSize } from '@/features/pip/hooks/usePipElementSize'
|
||||
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
|
||||
import { ReactionsPill } from './ReactionsPill'
|
||||
|
||||
const Container = styled('div', {
|
||||
type ReactionsToolbarProps = {
|
||||
toggleId?: string
|
||||
controlBarId?: string
|
||||
}
|
||||
|
||||
export const ReactionsToolbar = ({
|
||||
toggleId = 'reactions-toggle',
|
||||
controlBarId = 'control-bar',
|
||||
}: ReactionsToolbarProps) => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const renderContent = useDelayUnmount(isOpen, 500)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const { width: availableWidth } = usePipElementSize(wrapperRef)
|
||||
|
||||
useEffect(() => {
|
||||
const el = contentRef.current
|
||||
if (!el) return
|
||||
if (isOpen) el.removeAttribute('inert')
|
||||
else el.setAttribute('inert', '')
|
||||
}, [isOpen, renderContent])
|
||||
|
||||
return (
|
||||
<Wrapper ref={wrapperRef} isOpen={isOpen}>
|
||||
{renderContent && (
|
||||
<div ref={contentRef}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<FocusScope autoFocus>
|
||||
<ReactionsKeyboardNavigation
|
||||
toggleId={toggleId}
|
||||
controlBarId={controlBarId}
|
||||
>
|
||||
<ReactionsPill isOpen={isOpen} availableWidth={availableWidth} />
|
||||
</ReactionsKeyboardNavigation>
|
||||
</FocusScope>
|
||||
</div>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const Wrapper = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 'var(--sizes-room-control-bar)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
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',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 0,
|
||||
width: '100%',
|
||||
transition:
|
||||
'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
variants: {
|
||||
isVisible: {
|
||||
isOpen: {
|
||||
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',
|
||||
maxHeight: '60px',
|
||||
padding: '0.5rem 0',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
if (!shouldMount) return null
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export type ReactionsToolbarStore = {
|
||||
showReactionsToolbar: boolean
|
||||
}
|
||||
|
||||
const ReactionsToolbarStoreContext =
|
||||
createContext<ReactionsToolbarStore>(layoutStore)
|
||||
|
||||
export const ReactionsToolbarStoreProvider =
|
||||
ReactionsToolbarStoreContext.Provider
|
||||
|
||||
export const useReactionsToolbar = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const store = useContext(ReactionsToolbarStoreContext)
|
||||
const snap = useSnapshot(store)
|
||||
|
||||
return {
|
||||
isOpen: layoutSnap.showReactionsToolbar,
|
||||
isOpen: snap.showReactionsToolbar,
|
||||
toggle: () => {
|
||||
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
|
||||
store.showReactionsToolbar = !store.showReactionsToolbar
|
||||
},
|
||||
close: () => {
|
||||
store.showReactionsToolbar = false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Emoji } from '@/features/reactions/types'
|
||||
|
||||
export const EMOJI_SLOT_WIDTH = 40
|
||||
export const ARROW_SLOT_WIDTH = 32
|
||||
export const PILL_HORIZONTAL_PADDING = 12
|
||||
export const WRAPPER_HORIZONTAL_PADDING = 16
|
||||
|
||||
const EMOJIS = Object.values(Emoji)
|
||||
|
||||
export type ReactionsPage = {
|
||||
visibleEmojis: Emoji[]
|
||||
hasOverflow: boolean
|
||||
canGoLeft: boolean
|
||||
canGoRight: boolean
|
||||
visibleCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute how many emojis fit in `availableWidth` and slice the visible page.
|
||||
* Arrow slots are reserved only when the list overflows.
|
||||
*/
|
||||
export const computeReactionsPage = (
|
||||
availableWidth: number,
|
||||
pageStart: number
|
||||
): ReactionsPage => {
|
||||
const usableWidth =
|
||||
availableWidth - WRAPPER_HORIZONTAL_PADDING - PILL_HORIZONTAL_PADDING
|
||||
const maxWithoutArrows = Math.max(
|
||||
1,
|
||||
Math.floor(usableWidth / EMOJI_SLOT_WIDTH)
|
||||
)
|
||||
|
||||
if (EMOJIS.length <= maxWithoutArrows) {
|
||||
return {
|
||||
visibleEmojis: EMOJIS,
|
||||
hasOverflow: false,
|
||||
canGoLeft: false,
|
||||
canGoRight: false,
|
||||
visibleCount: EMOJIS.length,
|
||||
}
|
||||
}
|
||||
|
||||
const visibleCount = Math.max(
|
||||
1,
|
||||
Math.floor((usableWidth - ARROW_SLOT_WIDTH * 2) / EMOJI_SLOT_WIDTH)
|
||||
)
|
||||
const clampedStart = Math.min(
|
||||
Math.max(0, pageStart),
|
||||
Math.max(0, EMOJIS.length - visibleCount)
|
||||
)
|
||||
|
||||
return {
|
||||
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
|
||||
hasOverflow: true,
|
||||
canGoLeft: clampedStart > 0,
|
||||
canGoRight: clampedStart + visibleCount < EMOJIS.length,
|
||||
visibleCount,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMaxPageStart = (visibleCount: number): number =>
|
||||
Math.max(0, EMOJIS.length - visibleCount)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
@@ -156,6 +155,8 @@ export const SidePanel = () => {
|
||||
isInfoOpen,
|
||||
isSubPanelOpen,
|
||||
activeSubPanelId,
|
||||
closePanel,
|
||||
goBack,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||
@@ -166,10 +167,7 @@ export const SidePanel = () => {
|
||||
<StyledSidePanel
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
onClose={closePanel}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
@@ -177,7 +175,7 @@ export const SidePanel = () => {
|
||||
isSubmenu={isSubPanelOpen}
|
||||
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
onBack={goBack}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
|
||||
+2
@@ -7,6 +7,7 @@ import { EffectsMenuItem } from './EffectsMenuItem'
|
||||
import { SupportMenuItem } from './SupportMenuItem'
|
||||
import { TranscriptMenuItem } from './TranscriptMenuItem'
|
||||
import { ScreenRecordingMenuItem } from './ScreenRecordingMenuItem'
|
||||
import { PictureInPictureMenuItem } from './PictureInPictureMenuItem'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = () => {
|
||||
@@ -21,6 +22,7 @@ export const OptionsMenuItems = () => {
|
||||
<TranscriptMenuItem />
|
||||
<ScreenRecordingMenuItem />
|
||||
<FullScreenMenuItem />
|
||||
<PictureInPictureMenuItem />
|
||||
<EffectsMenuItem />
|
||||
</MenuSection>
|
||||
<Separator />
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiPictureInPicture2Line } from '@remixicon/react'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
|
||||
export const PictureInPictureMenuItem = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||
const { isSupported, isOpen, toggle } = useRoomPiP()
|
||||
|
||||
// Hide the entry when the browser doesn't support Document PiP.
|
||||
if (!isSupported) return null
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
onAction={toggle}
|
||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||
>
|
||||
<RiPictureInPicture2Line size={20} />
|
||||
{isOpen ? t('pictureInPicture.exit') : t('pictureInPicture.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,74 +1,84 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { PanelId, SubPanelId } from '../types/panel'
|
||||
|
||||
export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TOOLS = 'tools',
|
||||
ADMIN = 'admin',
|
||||
INFO = 'info',
|
||||
export { PanelId, SubPanelId } from '../types/panel'
|
||||
|
||||
export type SidePanelStore = {
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
}
|
||||
|
||||
export enum SubPanelId {
|
||||
TRANSCRIPT = 'transcript',
|
||||
SCREEN_RECORDING = 'screenRecording',
|
||||
}
|
||||
const SidePanelStoreContext = createContext<SidePanelStore>(layoutStore)
|
||||
|
||||
export const useSidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
export const SidePanelStoreProvider = SidePanelStoreContext.Provider
|
||||
|
||||
export const useSidePanel = (store?: SidePanelStore) => {
|
||||
const currentStore = useContext(SidePanelStoreContext)
|
||||
store ??= currentStore
|
||||
const layoutSnap = useSnapshot(store)
|
||||
const activePanelId = layoutSnap.activePanelId
|
||||
const activeSubPanelId = layoutSnap.activeSubPanelId
|
||||
|
||||
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId == PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId == PanelId.CHAT
|
||||
const isToolsOpen = activePanelId == PanelId.TOOLS
|
||||
const isAdminOpen = activePanelId == PanelId.ADMIN
|
||||
const isInfoOpen = activePanelId == PanelId.INFO
|
||||
const isTranscriptOpen = activeSubPanelId == SubPanelId.TRANSCRIPT
|
||||
const isScreenRecordingOpen = activeSubPanelId == SubPanelId.SCREEN_RECORDING
|
||||
const isParticipantsOpen = activePanelId === PanelId.PARTICIPANTS
|
||||
const isEffectsOpen = activePanelId === PanelId.EFFECTS
|
||||
const isChatOpen = activePanelId === PanelId.CHAT
|
||||
const isToolsOpen = activePanelId === PanelId.TOOLS
|
||||
const isAdminOpen = activePanelId === PanelId.ADMIN
|
||||
const isInfoOpen = activePanelId === PanelId.INFO
|
||||
const isTranscriptOpen = activeSubPanelId === SubPanelId.TRANSCRIPT
|
||||
const isScreenRecordingOpen = activeSubPanelId === SubPanelId.SCREEN_RECORDING
|
||||
const isSidePanelOpen = !!activePanelId
|
||||
const isSubPanelOpen = !!activeSubPanelId
|
||||
|
||||
const toggleAdmin = () => {
|
||||
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isAdminOpen ? null : PanelId.ADMIN
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleParticipants = () => {
|
||||
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isChatOpen ? null : PanelId.CHAT
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleTools = () => {
|
||||
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isToolsOpen ? null : PanelId.TOOLS
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const toggleInfo = () => {
|
||||
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
|
||||
store.activePanelId = isInfoOpen ? null : PanelId.INFO
|
||||
if (layoutSnap.activeSubPanelId) store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const openTranscript = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
store.activeSubPanelId = SubPanelId.TRANSCRIPT
|
||||
store.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const openScreenRecording = () => {
|
||||
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
layoutStore.activePanelId = PanelId.TOOLS
|
||||
store.activeSubPanelId = SubPanelId.SCREEN_RECORDING
|
||||
store.activePanelId = PanelId.TOOLS
|
||||
}
|
||||
|
||||
const closePanel = () => {
|
||||
store.activePanelId = null
|
||||
store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
store.activeSubPanelId = null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -82,6 +92,8 @@ export const useSidePanel = () => {
|
||||
toggleInfo,
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
closePanel,
|
||||
goBack,
|
||||
isSubPanelOpen,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -16,17 +16,20 @@ import { VideoDeviceControl } from '../../components/controls/Device/VideoDevice
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
isDisabled: isPiPOpen,
|
||||
handler: () => {
|
||||
const root = desktopControlBarEl.current
|
||||
if (!root) return
|
||||
|
||||
@@ -32,9 +32,11 @@ 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 { RoomPiP } from '@/features/pip/components/RoomPiP'
|
||||
import { PipPlaceholder } from '@/features/pip/components/PipPlaceholder'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||
@@ -91,7 +93,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
useConnectionObserver()
|
||||
useRoomPageTitle()
|
||||
useVideoResolutionSubscription()
|
||||
useSyncLiveKitMetadata()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'open-shortcuts',
|
||||
@@ -229,6 +230,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -250,32 +253,38 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
/>
|
||||
<IsIdleDisconnectModal />
|
||||
<RoomContentArea>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
{isPiPOpen ? (
|
||||
<PipPlaceholder />
|
||||
) : (
|
||||
<div
|
||||
className="lk-focus-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||
<CarouselLayout
|
||||
tracks={carouselTracks}
|
||||
style={{
|
||||
minWidth: '200px',
|
||||
}}
|
||||
<>
|
||||
{!focusTrack ? (
|
||||
<div
|
||||
className="lk-grid-layout-wrapper"
|
||||
style={{ height: 'auto' }}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout trackRef={focusTrack} />}
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</RoomContentArea>
|
||||
<ControlBar
|
||||
@@ -291,6 +300,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
}}
|
||||
/>
|
||||
<SidePanel />
|
||||
<RoomPiP />
|
||||
</LayoutContextProvider>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Panel identifiers for the side panel (Info, Chat, Participants, etc.).
|
||||
* Extracted to avoid circular dependencies between layout store and useSidePanel.
|
||||
*/
|
||||
export enum PanelId {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
TOOLS = 'tools',
|
||||
ADMIN = 'admin',
|
||||
INFO = 'info',
|
||||
}
|
||||
|
||||
export enum SubPanelId {
|
||||
TRANSCRIPT = 'transcript',
|
||||
SCREEN_RECORDING = 'screenRecording',
|
||||
}
|
||||
@@ -19,10 +19,14 @@ export const useRegisterKeyboardShortcut = ({
|
||||
const descriptor = getShortcutDescriptorById(id)
|
||||
if (!descriptor?.shortcut) return
|
||||
const formattedKey = formatShortcutKey(descriptor.shortcut)
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
} else {
|
||||
if (!isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
}
|
||||
return () => {
|
||||
// Remove only if this is still the registered handler
|
||||
if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
}
|
||||
}
|
||||
}, [handler, id, isDisabled])
|
||||
}
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Reaktion senden",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "du",
|
||||
"previousReactions": "Vorherige Reaktionen",
|
||||
"nextReactions": "Nächste Reaktionen",
|
||||
"emojis": {
|
||||
"thumbs-up": "Daumen hoch",
|
||||
"thumbs-down": "Daumen runter",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Deinen Namen aktualisieren",
|
||||
"effects": "Effekte anwenden",
|
||||
"switchCamera": "Kamera wechseln",
|
||||
"pictureInPicture": {
|
||||
"enter": "Bild-im-Bild",
|
||||
"exit": "Bild-im-Bild schließen",
|
||||
"opened": "Bild-im-Bild-Modus aktiviert",
|
||||
"closed": "Bild-im-Bild-Modus deaktiviert",
|
||||
"windowLabel": "Bild-im-Bild Besprechung",
|
||||
"stage": "Teilnehmer",
|
||||
"controlBar": "Besprechungssteuerung",
|
||||
"notificationsLabel": "Benachrichtigungen",
|
||||
"dismissNotification": "Benachrichtigung schließen",
|
||||
"connection": {
|
||||
"reconnecting": "Verbindung wird wiederhergestellt…",
|
||||
"disconnected": "Verbindung getrennt"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Ihr Videoanruf befindet sich in einem anderen Fenster.",
|
||||
"description": "Im Bild-im-Bild-Modus bleiben Sie mit dem Anruf verbunden, während Sie andere Aufgaben erledigen.",
|
||||
"bringBack": "Anruf hierher zurückholen"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Vollbild",
|
||||
"exit": "Vollbildmodus verlassen"
|
||||
@@ -528,10 +550,6 @@
|
||||
"screenshare": {
|
||||
"label": "Bildschirm teilen",
|
||||
"description": "Wenn du diese Option deaktivierst, können Teilnehmende ihren Bildschirm nicht mehr teilen. Laufende Bildschirmfreigaben werden sofort beendet."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Andere stummschalten",
|
||||
"description": "Wenn deaktiviert, können Teilnehmer andere Teilnehmer nicht mehr stummschalten."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Send reaction",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "you",
|
||||
"previousReactions": "Previous reactions",
|
||||
"nextReactions": "Next reactions",
|
||||
"emojis": {
|
||||
"thumbs-up": "thumbs up",
|
||||
"thumbs-down": "thumbs down",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Update Your Name",
|
||||
"effects": "Backgrounds and Effects",
|
||||
"switchCamera": "Switch camera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Picture-in-picture",
|
||||
"exit": "Close picture-in-picture",
|
||||
"opened": "Picture-in-picture mode enabled",
|
||||
"closed": "Picture-in-picture mode disabled",
|
||||
"windowLabel": "Picture-in-picture meeting",
|
||||
"stage": "Participants",
|
||||
"controlBar": "Meeting controls",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Dismiss notification",
|
||||
"connection": {
|
||||
"reconnecting": "Reconnecting…",
|
||||
"disconnected": "Disconnected"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Your video call is in another window.",
|
||||
"description": "Picture-in-Picture mode allows you to stay connected to the call while performing other tasks.",
|
||||
"bringBack": "Bring the call back here"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Fullscreen",
|
||||
"exit": "Exit fullscreen mode"
|
||||
@@ -527,10 +549,6 @@
|
||||
"screenshare": {
|
||||
"label": "Share their screen",
|
||||
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Mute others",
|
||||
"description": "When disabled, participants will no longer be able to mute other participants."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Envoyer une réaction",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "vous",
|
||||
"previousReactions": "Réactions précédentes",
|
||||
"nextReactions": "Réactions suivantes",
|
||||
"emojis": {
|
||||
"thumbs-up": "pouce levé",
|
||||
"thumbs-down": "pouce baissé",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Choisir votre nom",
|
||||
"effects": "Arrière-plans et effets",
|
||||
"switchCamera": "Changer de caméra",
|
||||
"pictureInPicture": {
|
||||
"enter": "Image dans l'image",
|
||||
"exit": "Fermer l'image dans l'image",
|
||||
"opened": "Mode image dans l'image activé",
|
||||
"closed": "Mode image dans l'image désactivé",
|
||||
"windowLabel": "Réunion en image dans l'image",
|
||||
"stage": "Participants",
|
||||
"controlBar": "Commandes de la réunion",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Fermer la notification",
|
||||
"connection": {
|
||||
"reconnecting": "Reconnexion…",
|
||||
"disconnected": "Déconnecté"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Votre appel vidéo est dans une autre fenêtre.",
|
||||
"description": "Le mode image dans l'image vous permet de rester connecté à l'appel tout en effectuant d'autres tâches.",
|
||||
"bringBack": "Ramener l'appel ici"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Plein écran",
|
||||
"exit": "Quitter le mode plein écran"
|
||||
@@ -527,10 +549,6 @@
|
||||
"screenshare": {
|
||||
"label": "Partager leur écran",
|
||||
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Muter les autres",
|
||||
"description": "En désactivant cette option, les participants ne pourront plus muter d'autres participants."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Stuur reactie",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "U",
|
||||
"previousReactions": "Vorige reacties",
|
||||
"nextReactions": "Volgende reacties",
|
||||
"emojis": {
|
||||
"thumbs-up": "duim omhoog",
|
||||
"thumbs-down": "duim omlaag",
|
||||
@@ -241,6 +243,26 @@
|
||||
"username": "Verander uw naam",
|
||||
"effects": "Pas effecten toe",
|
||||
"switchCamera": "Selecteer camera",
|
||||
"pictureInPicture": {
|
||||
"enter": "Beeld-in-beeld",
|
||||
"exit": "Beeld-in-beeld sluiten",
|
||||
"opened": "Beeld-in-beeld-modus ingeschakeld",
|
||||
"closed": "Beeld-in-beeld-modus uitgeschakeld",
|
||||
"windowLabel": "Beeld-in-beeld vergadering",
|
||||
"stage": "Deelnemers",
|
||||
"controlBar": "Vergaderbesturing",
|
||||
"notificationsLabel": "Meldingen",
|
||||
"dismissNotification": "Melding sluiten",
|
||||
"connection": {
|
||||
"reconnecting": "Opnieuw verbinden…",
|
||||
"disconnected": "Verbinding verbroken"
|
||||
},
|
||||
"placeholder": {
|
||||
"title": "Uw videogesprek bevindt zich in een ander venster.",
|
||||
"description": "Met de beeld-in-beeld-modus kunt u verbonden blijven met het gesprek terwijl u andere taken uitvoert.",
|
||||
"bringBack": "Gesprek hier terughalen"
|
||||
}
|
||||
},
|
||||
"fullscreen": {
|
||||
"enter": "Volledig scherm",
|
||||
"exit": "Stop volledig scherm stand"
|
||||
@@ -527,10 +549,6 @@
|
||||
"screenshare": {
|
||||
"label": "Hun scherm delen",
|
||||
"description": "Als u deze optie uitschakelt, kunnen deelnemers hun scherm niet meer delen en wordt elke lopende schermdeling onmiddellijk gestopt."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Anderen dempen",
|
||||
"description": "Wanneer uitgeschakeld, kunnen deelnemers andere deelnemers niet meer dempen."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,24 +2,29 @@ import { ReactNode } from 'react'
|
||||
import { MenuTrigger } from 'react-aria-components'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { Box } from './Box'
|
||||
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
|
||||
|
||||
/**
|
||||
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
|
||||
*
|
||||
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
|
||||
*/
|
||||
export const Menu = ({
|
||||
children,
|
||||
variant = 'light',
|
||||
placement,
|
||||
placement = 'top',
|
||||
}: {
|
||||
children: [trigger: ReactNode, menu: ReactNode]
|
||||
variant?: 'dark' | 'light'
|
||||
placement?: 'bottom' | 'top' | 'left' | 'right'
|
||||
}) => {
|
||||
const [trigger, menu] = children
|
||||
const boundaryElement = useOverlayBoundaryElement()
|
||||
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<StyledPopover placement={placement}>
|
||||
<StyledPopover placement={placement} boundaryElement={boundaryElement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
{menu}
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Box } from './Box'
|
||||
import { useOverlayBoundaryElement } from './useOverlayPortalContainer'
|
||||
|
||||
export const StyledPopover = styled(RACPopover, {
|
||||
base: {
|
||||
@@ -65,6 +66,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
||||
*
|
||||
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
|
||||
* This is here when needing to show unrestricted content in a box.
|
||||
*
|
||||
* Uses UNSAFE_PortalProvider context automatically for portal container (no need for UNSTABLE_portalContainer).
|
||||
*/
|
||||
export const Popover = ({
|
||||
children,
|
||||
@@ -82,10 +85,11 @@ export const Popover = ({
|
||||
withArrow?: boolean
|
||||
} & Omit<DialogProps, 'children'>) => {
|
||||
const [trigger, popoverContent] = children
|
||||
const boundaryElement = useOverlayBoundaryElement()
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<StyledPopover>
|
||||
<StyledPopover boundaryElement={boundaryElement}>
|
||||
{withArrow && (
|
||||
<StyledOverlayArrow variant={variant}>
|
||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactElement, type ReactNode } from 'react'
|
||||
import {
|
||||
OverlayArrow,
|
||||
Tooltip as RACTooltip,
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
type TooltipProps,
|
||||
} from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { VisualOnlyTooltip } from './VisualOnlyTooltip'
|
||||
import { useVisualOnlyTooltips } from './VisualOnlyTooltipsContext'
|
||||
|
||||
export type TooltipWrapperProps = {
|
||||
tooltip?: string
|
||||
tooltipType?: 'instant' | 'delayed'
|
||||
}
|
||||
|
||||
const INSTANT_TOOLTIP_DELAY_MS = 150
|
||||
const DELAYED_TOOLTIP_DELAY_MS = 1000
|
||||
|
||||
/**
|
||||
* Wrap a component you want to apply a tooltip on (for example a Button)
|
||||
*
|
||||
@@ -22,15 +27,25 @@ export const TooltipWrapper = ({
|
||||
tooltipType,
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
children: ReactElement
|
||||
} & TooltipWrapperProps) => {
|
||||
return tooltip ? (
|
||||
<TooltipTrigger delay={tooltipType === 'instant' ? 150 : 1000}>
|
||||
const visualOnly = useVisualOnlyTooltips()
|
||||
const tooltipDelay =
|
||||
tooltipType === 'instant'
|
||||
? INSTANT_TOOLTIP_DELAY_MS
|
||||
: DELAYED_TOOLTIP_DELAY_MS
|
||||
|
||||
if (!tooltip) return children
|
||||
|
||||
if (visualOnly) {
|
||||
return <VisualOnlyTooltip tooltip={tooltip}>{children}</VisualOnlyTooltip>
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipTrigger delay={tooltipDelay}>
|
||||
{children}
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
) : (
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +54,8 @@ export const TooltipWrapper = ({
|
||||
*
|
||||
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
|
||||
*/
|
||||
const DEFAULT_TOOLTIP_GAP_PX = 8
|
||||
|
||||
const StyledTooltip = styled(RACTooltip, {
|
||||
base: {
|
||||
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
||||
@@ -53,11 +70,11 @@ const StyledTooltip = styled(RACTooltip, {
|
||||
fontSize: 14,
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
'&[data-placement=top]': {
|
||||
marginBottom: '8px',
|
||||
marginBottom: `${DEFAULT_TOOLTIP_GAP_PX}px`,
|
||||
'--origin': 'translateY(4px)',
|
||||
},
|
||||
'&[data-placement=bottom]': {
|
||||
marginTop: '8px',
|
||||
marginTop: `${DEFAULT_TOOLTIP_GAP_PX}px`,
|
||||
'--origin': 'translateY(-4px)',
|
||||
},
|
||||
'&[data-placement=right]': {
|
||||
@@ -107,10 +124,13 @@ const TooltipArrow = () => {
|
||||
|
||||
const Tooltip = ({
|
||||
children,
|
||||
arrowBoundaryOffset,
|
||||
...props
|
||||
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
|
||||
}: {
|
||||
children: ReactNode
|
||||
} & Partial<Omit<TooltipProps, 'children'>>) => {
|
||||
return (
|
||||
<StyledTooltip {...props}>
|
||||
<StyledTooltip arrowBoundaryOffset={arrowBoundaryOffset ?? 0} {...props}>
|
||||
<TooltipArrow />
|
||||
{children}
|
||||
</StyledTooltip>
|
||||
|
||||
@@ -2,11 +2,14 @@ import {
|
||||
type ReactElement,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
|
||||
|
||||
export type VisualOnlyTooltipProps = {
|
||||
children: ReactElement
|
||||
@@ -32,19 +35,29 @@ export const VisualOnlyTooltip = ({
|
||||
tooltipPosition = 'top',
|
||||
}: VisualOnlyTooltipProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const { getContainer } = useUNSAFE_PortalContext()
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState<{
|
||||
top: number
|
||||
left: number
|
||||
} | null>(null)
|
||||
const [computedStyle, setComputedStyle] = useState<{
|
||||
left: number
|
||||
arrowLeft: number
|
||||
} | null>(null)
|
||||
|
||||
const isBottom = tooltipPosition === 'bottom'
|
||||
const [effectiveBottom, setEffectiveBottom] = useState(
|
||||
tooltipPosition === 'bottom'
|
||||
)
|
||||
|
||||
const showTooltip = () => {
|
||||
if (!wrapperRef.current) return
|
||||
const rect = wrapperRef.current.getBoundingClientRect()
|
||||
const preferBottom = tooltipPosition === 'bottom'
|
||||
setEffectiveBottom(preferBottom)
|
||||
setPosition({
|
||||
top: isBottom ? rect.bottom + 8 : rect.top - 8,
|
||||
top: preferBottom ? rect.bottom + 8 : rect.top - 8,
|
||||
left: rect.left + rect.width / 2,
|
||||
})
|
||||
setIsVisible(true)
|
||||
@@ -53,15 +66,67 @@ export const VisualOnlyTooltip = ({
|
||||
const hideTooltip = () => {
|
||||
setIsVisible(false)
|
||||
setPosition(null)
|
||||
setComputedStyle(null)
|
||||
}
|
||||
|
||||
const tooltipData = isVisible && position ? { isVisible, position } : null
|
||||
useLayoutEffect(() => {
|
||||
if (!tooltipRef.current || !wrapperRef.current || !isVisible || !position)
|
||||
return
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect()
|
||||
const triggerRect = wrapperRef.current.getBoundingClientRect()
|
||||
const doc = tooltipRef.current.ownerDocument
|
||||
const viewportWidth = doc.defaultView?.innerWidth ?? globalThis.innerWidth
|
||||
const padding = 8
|
||||
|
||||
// Vertical flip: if tooltip overflows the top, switch to bottom
|
||||
if (!effectiveBottom && position.top - tooltipRect.height < 0) {
|
||||
const flippedTop = triggerRect.bottom + 8
|
||||
setEffectiveBottom(true)
|
||||
setPosition({ top: flippedTop, left: position.left })
|
||||
return
|
||||
}
|
||||
|
||||
// Horizontal clamping (both edges)
|
||||
const desiredLeft = position.left - tooltipRect.width / 2
|
||||
const minLeft = padding
|
||||
const maxLeft = viewportWidth - padding - tooltipRect.width
|
||||
|
||||
if (desiredLeft >= minLeft && desiredLeft <= maxLeft) {
|
||||
setComputedStyle(null)
|
||||
return
|
||||
}
|
||||
|
||||
const clampedLeft = Math.max(minLeft, Math.min(maxLeft, desiredLeft))
|
||||
setComputedStyle({
|
||||
left: clampedLeft,
|
||||
arrowLeft: position.left - clampedLeft,
|
||||
})
|
||||
}, [isVisible, position, effectiveBottom])
|
||||
|
||||
const portalContainer = useMemo(() => {
|
||||
if (getContainer) return getContainer()
|
||||
return wrapperRef.current?.ownerDocument?.body ?? document.body
|
||||
}, [getContainer])
|
||||
const wrappedChild = isValidElement(children)
|
||||
? cloneElement(children, {
|
||||
...(ariaLabel ? { 'aria-label': ariaLabel } : {}),
|
||||
})
|
||||
: children
|
||||
|
||||
const translateY = effectiveBottom ? 'translateY(0)' : 'translateY(-100%)'
|
||||
const translateXY = effectiveBottom
|
||||
? 'translate(-50%, 0)'
|
||||
: 'translate(-50%, -100%)'
|
||||
|
||||
const tooltipInlineStyle: React.CSSProperties & Record<string, string> = {
|
||||
top: `${position?.top}px`,
|
||||
left: computedStyle ? `${computedStyle.left}px` : `${position?.left}px`,
|
||||
transform: computedStyle ? translateY : translateXY,
|
||||
...(computedStyle
|
||||
? { '--tooltip-arrow-left': `${computedStyle.arrowLeft}px` }
|
||||
: null),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -73,11 +138,14 @@ export const VisualOnlyTooltip = ({
|
||||
>
|
||||
{wrappedChild}
|
||||
</div>
|
||||
{tooltipData &&
|
||||
{isVisible &&
|
||||
position &&
|
||||
portalContainer &&
|
||||
createPortal(
|
||||
<div
|
||||
aria-hidden="true"
|
||||
role="presentation"
|
||||
ref={tooltipRef}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
padding: '2px 8px',
|
||||
@@ -87,15 +155,15 @@ export const VisualOnlyTooltip = ({
|
||||
fontSize: 14,
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
zIndex: 100001,
|
||||
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
left: 'var(--tooltip-arrow-left, 50%)',
|
||||
transform: 'translateX(-50%)',
|
||||
border: '4px solid transparent',
|
||||
...(isBottom
|
||||
...(effectiveBottom
|
||||
? {
|
||||
bottom: '100%',
|
||||
borderBottomColor: 'primaryDark.100',
|
||||
@@ -106,17 +174,11 @@ export const VisualOnlyTooltip = ({
|
||||
}),
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
top: `${tooltipData.position.top}px`,
|
||||
left: `${tooltipData.position.left}px`,
|
||||
transform: isBottom
|
||||
? 'translate(-50%, 0)'
|
||||
: 'translate(-50%, -100%)',
|
||||
}}
|
||||
style={tooltipInlineStyle}
|
||||
>
|
||||
{tooltip}
|
||||
</div>,
|
||||
document.body
|
||||
portalContainer
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* When true, tooltips render as visual-only (no aria-describedby).
|
||||
* Provided by surfaces where React Aria TooltipTrigger doesn't work
|
||||
* correctly (e.g. cross-document portals).
|
||||
*/
|
||||
export const VisualOnlyTooltipsContext = createContext(false)
|
||||
|
||||
export const useVisualOnlyTooltips = () => useContext(VisualOnlyTooltipsContext)
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useUNSAFE_PortalContext } from '@react-aria/overlays'
|
||||
|
||||
/**
|
||||
* Hook to retrieve the portal container for overlays (menus, tooltips, popovers).
|
||||
* Returns the container from UNSAFE_PortalProvider context (pip-root in PiP, undefined in main window).
|
||||
*/
|
||||
export const useOverlayPortalContainer = () => {
|
||||
const { getContainer } = useUNSAFE_PortalContext()
|
||||
|
||||
return useMemo(() => getContainer?.() ?? undefined, [getContainer])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to retrieve the boundary element for overlay positioning.
|
||||
* Returns the portal container in PiP (for PiP-relative positioning), undefined in main window.
|
||||
*/
|
||||
export const useOverlayBoundaryElement = () => {
|
||||
const portalContainer = useOverlayPortalContainer()
|
||||
return portalContainer
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||
}
|
||||
|
||||
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||
default: 'transparent',
|
||||
default: 'rgba(0, 0, 0, 0.75)',
|
||||
black: 'rgba(0, 0, 0, 0.75)',
|
||||
white: 'rgba(255, 255, 255, 0.75)',
|
||||
blue: 'rgba(0, 0, 255, 0.75)',
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { proxy } from 'valtio'
|
||||
import {
|
||||
PanelId,
|
||||
SubPanelId,
|
||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { PanelId, SubPanelId } from '@/features/rooms/livekit/types/panel'
|
||||
|
||||
type State = {
|
||||
showHeader: boolean
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const roomPiPStore = proxy<State>({
|
||||
isOpen: false,
|
||||
})
|
||||
@@ -1,6 +1,20 @@
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
||||
|
||||
/**
|
||||
* Find the first focusable descendant of `root`.
|
||||
* Works across documents (useful for the PiP window, which has its own
|
||||
* `document`). Pass the result of `ownerDocument.getElementById(...)` to
|
||||
* target an element in a specific document.
|
||||
*/
|
||||
export const findFirstFocusable = (
|
||||
root: HTMLElement | null | undefined
|
||||
): HTMLElement | null =>
|
||||
root?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? null
|
||||
|
||||
/**
|
||||
* Wrapper for the main document. Use `findFirstFocusable` when
|
||||
* working with a non-main document (e.g. the PiP window).
|
||||
*/
|
||||
export const getFirstControlBarFocusable = (id: string): HTMLElement | null =>
|
||||
document
|
||||
.getElementById(id)
|
||||
?.querySelector(
|
||||
'input, select, textarea, button, object, a, area[href], [tabindex]'
|
||||
) ?? null
|
||||
findFirstFocusable(document.getElementById(id))
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
@@ -1588,9 +1588,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.16.0",
|
||||
"version": "1.15.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.16.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -105,9 +105,6 @@ class Settings(BaseSettings):
|
||||
|
||||
# Speaker to user assignment
|
||||
is_resolve_speaker_identities_enabled: bool = True
|
||||
resolve_speaker_identities_default_overlap_threshold: float = 0.5
|
||||
resolve_speaker_identities_enable_split_on_words: bool = True
|
||||
resolve_speaker_identities_max_word_duration: float = 1 # seconds
|
||||
|
||||
# Webhook-related settings
|
||||
webhook_max_retries: int = 2
|
||||
|
||||
@@ -8,19 +8,18 @@ Multiple speakers can map to the same participant (e.g. two people sharing
|
||||
one microphone). A participant with no matching speaker gets no assignment.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum fraction of a speaker's total duration that must overlap with a
|
||||
# participant's VAD to accept the assignment.
|
||||
DEFAULT_OVERLAP_THRESHOLD = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class Interval:
|
||||
@@ -75,7 +74,7 @@ class AssignmentResult:
|
||||
f" ({item['speaker']})" if name_to_speaker_count[name] > 1 else ""
|
||||
) # Add suffix only if there are multiple detected speakers per user
|
||||
return {**item, "speaker": f"{name}{suffix}"}
|
||||
return {**item}
|
||||
return item
|
||||
|
||||
def _process_segment(
|
||||
item: dict[str, Any], include_words: bool = False
|
||||
@@ -139,78 +138,6 @@ def _overlap_duration(
|
||||
return overlap
|
||||
|
||||
|
||||
def _format_timelines_debug(
|
||||
participant_timelines: dict[str, list[Interval]],
|
||||
participant_names: dict[str, str],
|
||||
speaker_timelines: dict[str, list[Interval]],
|
||||
) -> str:
|
||||
"""Render participant and speaker timelines side-by-side for debugging.
|
||||
|
||||
Each row is the slice between two consecutive interval boundaries
|
||||
(drawn from both sides). A filled cell marks an active participant
|
||||
(left block) or speaker (right block) during that slice, so vertical
|
||||
alignment makes overlap visually obvious.
|
||||
"""
|
||||
participant_ids = sorted(participant_timelines.keys())
|
||||
speaker_labels = sorted(speaker_timelines.keys())
|
||||
|
||||
if not participant_ids and not speaker_labels:
|
||||
return "(no timelines)"
|
||||
|
||||
boundaries: set[float] = set()
|
||||
for intervals in (*participant_timelines.values(), *speaker_timelines.values()):
|
||||
for iv in intervals:
|
||||
boundaries.add(iv.start)
|
||||
boundaries.add(iv.end)
|
||||
sorted_boundaries = sorted(boundaries)
|
||||
if len(sorted_boundaries) < 2:
|
||||
return "(no intervals)"
|
||||
|
||||
p_headers = [participant_names.get(pid, pid) for pid in participant_ids]
|
||||
s_headers = list(speaker_labels)
|
||||
p_widths = [max(len(h), 3) for h in p_headers]
|
||||
s_widths = [max(len(h), 3) for h in s_headers]
|
||||
|
||||
def _active(intervals: list[Interval], lo: float, hi: float) -> bool:
|
||||
mid = (lo + hi) / 2
|
||||
return any(iv.start <= mid < iv.end for iv in intervals)
|
||||
|
||||
def _cells(
|
||||
intervals_list: list[list[Interval]],
|
||||
widths: list[int],
|
||||
lo: float,
|
||||
hi: float,
|
||||
) -> str:
|
||||
return " ".join(
|
||||
("█" * w if _active(iv, lo, hi) else "·" * w)
|
||||
for iv, w in zip(intervals_list, widths, strict=True)
|
||||
)
|
||||
|
||||
p_iv_list = [participant_timelines[pid] for pid in participant_ids]
|
||||
s_iv_list = [speaker_timelines[sl] for sl in speaker_labels]
|
||||
|
||||
time_col = "[ start → end]"
|
||||
p_hdr = (
|
||||
" ".join(h.center(w) for h, w in zip(p_headers, p_widths, strict=True))
|
||||
or "(none)"
|
||||
)
|
||||
s_hdr = (
|
||||
" ".join(h.center(w) for h, w in zip(s_headers, s_widths, strict=True))
|
||||
or "(none)"
|
||||
)
|
||||
sep = " || "
|
||||
lines = [
|
||||
f"{time_col} {p_hdr}{sep}{s_hdr}",
|
||||
"-" * (len(time_col) + 2 + len(p_hdr) + len(sep) + len(s_hdr)),
|
||||
]
|
||||
for lo, hi in zip(sorted_boundaries, sorted_boundaries[1:], strict=False):
|
||||
time_str = f"[{lo:8.2f} → {hi:8.2f}]"
|
||||
p_row = _cells(p_iv_list, p_widths, lo, hi) or " " * len(p_hdr)
|
||||
s_row = _cells(s_iv_list, s_widths, lo, hi) or " " * len(s_hdr)
|
||||
lines.append(f"{time_str} {p_row}{sep}{s_row}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_participant_timelines(
|
||||
metadata: dict[str, Any],
|
||||
recording_start_datetime: datetime,
|
||||
@@ -272,77 +199,33 @@ def _build_participant_timelines(
|
||||
return intervals, participants_info
|
||||
|
||||
|
||||
def _build_speaker_timelines(transcription: Any) -> dict[str, list[Interval]]:
|
||||
def _build_speaker_timelines(
|
||||
transcription: Any,
|
||||
) -> dict[str, list[Interval]]:
|
||||
"""Build interval timelines from WhisperX transcription segments."""
|
||||
intervals: dict[str, list[Interval]] = {}
|
||||
segments = transcription.segments if hasattr(transcription, "segments") else []
|
||||
max_word_duration = settings.resolve_speaker_identities_max_word_duration
|
||||
|
||||
segments = transcription.segments if hasattr(transcription, "segments") else []
|
||||
for segment in segments:
|
||||
speaker = segment.get("speaker")
|
||||
if speaker is None:
|
||||
continue
|
||||
|
||||
words = [
|
||||
w
|
||||
for w in segment.get("words", [])
|
||||
if w.get("start") is not None and w.get("end") is not None
|
||||
]
|
||||
if not words:
|
||||
intervals.setdefault(speaker, []).append(
|
||||
Interval(segment["start"], segment["end"])
|
||||
)
|
||||
continue
|
||||
|
||||
start_time: float | None = segment["start"]
|
||||
for word in words:
|
||||
if start_time is None:
|
||||
start_time = word["start"]
|
||||
if not settings.resolve_speaker_identities_enable_split_on_words:
|
||||
continue
|
||||
if word["end"] - word["start"] > max_word_duration:
|
||||
end_time = word["start"] + max_word_duration
|
||||
if end_time > start_time:
|
||||
intervals.setdefault(speaker, []).append(
|
||||
Interval(start_time, end_time)
|
||||
)
|
||||
start_time = None
|
||||
|
||||
if start_time is not None:
|
||||
last = words[-1]
|
||||
end_time = min(last["end"], last["start"] + max_word_duration)
|
||||
if end_time > start_time:
|
||||
intervals.setdefault(speaker, []).append(Interval(start_time, end_time))
|
||||
intervals.setdefault(speaker, []).append(
|
||||
Interval(segment["start"], segment["end"])
|
||||
)
|
||||
|
||||
for speaker, speaker_intervals in intervals.items():
|
||||
intervals[speaker] = _merge_intervals(speaker_intervals)
|
||||
|
||||
return intervals
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
"""Encode datetimes, dataclasses, and pydantic models for `json.dumps`.
|
||||
|
||||
Intended to be used for logging of `resolve_speaker_identities` (input
|
||||
and computed variables)
|
||||
"""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return asdict(obj)
|
||||
if hasattr(obj, "segments") and hasattr(obj, "word_segments"):
|
||||
return {"segments": obj.segments, "word_segments": obj.word_segments}
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump(mode="json")
|
||||
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def resolve_speaker_identities(
|
||||
metadata: dict[str, Any],
|
||||
transcription: Any,
|
||||
recording_start_datetime: datetime,
|
||||
recording_end_datetime: datetime,
|
||||
overlap_threshold: float = settings.resolve_speaker_identities_default_overlap_threshold, # noqa: E501
|
||||
overlap_threshold: float = DEFAULT_OVERLAP_THRESHOLD,
|
||||
) -> AssignmentResult:
|
||||
"""Match WhisperX speaker labels to participants.
|
||||
|
||||
@@ -363,6 +246,12 @@ def resolve_speaker_identities(
|
||||
)
|
||||
speaker_timelines = _build_speaker_timelines(transcription)
|
||||
|
||||
logger.debug(
|
||||
"Assignment inputs: %d participants, %d speakers",
|
||||
len(participant_timelines),
|
||||
len(speaker_timelines),
|
||||
)
|
||||
|
||||
result = AssignmentResult()
|
||||
|
||||
for speaker, speaker_intervals in speaker_timelines.items():
|
||||
@@ -405,30 +294,4 @@ def resolve_speaker_identities(
|
||||
overlap_threshold,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
json.dumps(
|
||||
{
|
||||
"input": {
|
||||
"recording_start_datetime": recording_start_datetime.isoformat(),
|
||||
"recording_end_datetime": recording_end_datetime.isoformat(),
|
||||
"metadata": metadata,
|
||||
"transcription": transcription,
|
||||
},
|
||||
"computed": {
|
||||
"speaker_timelines": speaker_timelines,
|
||||
"participant_timelines": participant_timelines,
|
||||
"result": result,
|
||||
},
|
||||
},
|
||||
default=_json_default,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
logger.debug(
|
||||
_format_timelines_debug(
|
||||
participant_timelines, participant_names, speaker_timelines
|
||||
),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -4,12 +4,10 @@ import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
from summary.core import user_assign
|
||||
from summary.core.user_assign import (
|
||||
AssignmentResult,
|
||||
Interval,
|
||||
SpeakerAssignment,
|
||||
_build_speaker_timelines,
|
||||
_merge_intervals,
|
||||
_overlap_duration,
|
||||
_total_duration,
|
||||
@@ -75,22 +73,12 @@ DIARIZATION_SINGLE_SPEAKER = FakeTranscription(
|
||||
"end": 3.545,
|
||||
"text": " The stale smell.",
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "The", "start": 1.363, "end": 1.8},
|
||||
{"word": "stale", "start": 1.8, "end": 2.7},
|
||||
{"word": "smell.", "start": 2.7, "end": 3.545},
|
||||
],
|
||||
},
|
||||
{
|
||||
"start": 4.466,
|
||||
"end": 6.247,
|
||||
"text": "It takes heat.",
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "It", "start": 4.466, "end": 4.7},
|
||||
{"word": "takes", "start": 4.7, "end": 5.5},
|
||||
{"word": "heat.", "start": 5.5, "end": 6.247},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
@@ -177,174 +165,6 @@ class TestTotalDuration:
|
||||
assert math.isclose(_total_duration([]), 0.0)
|
||||
|
||||
|
||||
class TestBuildSpeakerTimelines:
|
||||
"""Tests for _build_speaker_timelines."""
|
||||
|
||||
def test_segment_without_words_falls_back_to_segment_bounds(self):
|
||||
"""Segments missing a `words` key use the segment start/end as one interval."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[{"start": 1.5, "end": 3.5, "speaker": "SPEAKER_00"}],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {"SPEAKER_00": [Interval(1.5, 3.5)]}
|
||||
|
||||
def test_segment_with_only_none_word_timestamps_falls_back(self):
|
||||
"""If every word has None start/end, fall back to segment bounds."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 1.0,
|
||||
"end": 4.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "hi", "start": None, "end": None},
|
||||
{"word": "there", "start": None, "end": None},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {"SPEAKER_00": [Interval(1.0, 4.0)]}
|
||||
|
||||
def test_short_words_only_uses_segment_start_and_last_word_end(self):
|
||||
"""With no overly long words, the interval runs segment start to end."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 1.0,
|
||||
"end": 5.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 1.0, "end": 1.3},
|
||||
{"word": "b", "start": 1.4, "end": 1.7},
|
||||
{"word": "c", "start": 1.8, "end": 2.1},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# Tail: min(2.1, 1.8 + 1.0) = 2.1
|
||||
assert result == {"SPEAKER_00": [Interval(1.0, 2.1)]}
|
||||
|
||||
def test_long_word_caps_interval_at_max_duration(self):
|
||||
"""A word longer than the max-word-duration cap truncates the segment."""
|
||||
max_word_duration = (
|
||||
user_assign.settings.resolve_speaker_identities_max_word_duration
|
||||
)
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": max_word_duration + 7,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{
|
||||
"word": "pause",
|
||||
"start": 0.0,
|
||||
"end": max_word_duration + 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {"SPEAKER_00": [Interval(0.0, max_word_duration)]}
|
||||
|
||||
def test_long_word_in_middle_splits_segment(self):
|
||||
"""Short words around a long word produce two intervals (before-cap + after)."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 20.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 0.0, "end": 0.5},
|
||||
{"word": "long", "start": 1.0, "end": 15.0},
|
||||
{"word": "z", "start": 18.0, "end": 18.4},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# First emit: (0.0, 1.0 + 1.0). Then start_time resets, picks up at "z" (18.0).
|
||||
# Tail: min(18.4, 18.0 + 1.0) = 18.4. So second interval is (18.0, 18.4).
|
||||
assert result == {
|
||||
"SPEAKER_00": [Interval(0.0, 2.0), Interval(18.0, 18.4)],
|
||||
}
|
||||
|
||||
def test_tail_word_is_capped_at_max_duration(self):
|
||||
"""The trailing word's end is capped at word.start + max_word_duration."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 50.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 0.0, "end": 0.4},
|
||||
# Last word ends inside the cap, so the cap doesn't apply.
|
||||
{"word": "b", "start": 1.0, "end": 1.5},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# Tail: min(1.5, 1.0 + 1.0) = 1.5
|
||||
assert result == {"SPEAKER_00": [Interval(0.0, 1.5)]}
|
||||
|
||||
def test_split_on_words_disabled_keeps_segment_as_one_interval(self, monkeypatch):
|
||||
"""With splitting disabled, long words don't split the interval."""
|
||||
monkeypatch.setattr(
|
||||
user_assign,
|
||||
"settings",
|
||||
user_assign.settings.model_copy(
|
||||
update={"resolve_speaker_identities_enable_split_on_words": False},
|
||||
),
|
||||
)
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 20.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [
|
||||
{"word": "a", "start": 0.0, "end": 0.5},
|
||||
{"word": "long", "start": 1.0, "end": 15.0},
|
||||
{"word": "z", "start": 18.0, "end": 18.4},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
# No mid-segment split; tail caps at min(18.4, 18.0 + 1.0) = 18.4.
|
||||
assert result == {"SPEAKER_00": [Interval(0.0, 18.4)]}
|
||||
|
||||
def test_multiple_speakers_keep_separate_timelines(self):
|
||||
"""Segments from different speakers populate independent timeline entries."""
|
||||
transcription = FakeTranscription(
|
||||
segments=[
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"speaker": "SPEAKER_00",
|
||||
"words": [{"word": "hi", "start": 0.0, "end": 0.5}],
|
||||
},
|
||||
{
|
||||
"start": 2.0,
|
||||
"end": 3.0,
|
||||
"speaker": "SPEAKER_01",
|
||||
"words": [{"word": "yo", "start": 2.0, "end": 2.5}],
|
||||
},
|
||||
],
|
||||
)
|
||||
result = _build_speaker_timelines(transcription)
|
||||
assert result == {
|
||||
"SPEAKER_00": [Interval(0.0, 0.5)],
|
||||
"SPEAKER_01": [Interval(2.0, 2.5)],
|
||||
}
|
||||
|
||||
|
||||
class TestResolveSpeakerIdentities:
|
||||
"""Tests for resolve_speaker_identities."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user