mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
80 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 | |||
| 964b3cd452 | |||
| c7ca5a621f | |||
| 90ebe231ef | |||
| 04f2a9ebdc | |||
| 6a8eb79b41 | |||
| bc35046b3a | |||
| 1612d8b2d4 | |||
| f8937fc0a1 | |||
| 97b5e3e65c | |||
| b917d82f7e | |||
| 82d146cdf5 | |||
| cbfeea0a4e | |||
| a695758da4 | |||
| 4c5b6de8f3 | |||
| cf4e347589 | |||
| fc260b2686 | |||
| cd7799997e | |||
| a2bccf4f4f | |||
| 6830250f2c | |||
| 0c0ce87947 | |||
| 597eba6e8a | |||
| 47dbc271ba | |||
| c3adcc8ff3 |
@@ -322,6 +322,11 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
@@ -8,6 +8,27 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♻(frontend) standardize role terminology across localizations
|
||||
- 🐛(backend) make start-recording atomic and fault-tolerant
|
||||
- 🔒️(frontend) room ids are generated with non-cryptographic rand
|
||||
- ⬆️(mail) fix dependencies not having resolved or integrity field #1321
|
||||
- 🐛(summary) complete webm support #1328
|
||||
- 🐛(backend) add link to "Open" text in recording email
|
||||
|
||||
## [1.15.0] - 2026-04-30
|
||||
|
||||
### Added
|
||||
@@ -45,6 +66,7 @@ and this project adheres to
|
||||
- ✨(summary) allow more file extensions #1265
|
||||
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
|
||||
- ♿️(frontend) set an explicit document title on recording download page #1261
|
||||
- ♿️(frontend) add customizable accessibility fonts #1270
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'liv
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe-default', resource_deps=['redis'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
|
||||
@@ -101,6 +101,12 @@ update_npm_version "mail"
|
||||
# Update backend pyproject.toml
|
||||
update_python_version "backend"
|
||||
|
||||
# Run uv lock in backend
|
||||
print_info "Running uv lock in backend..."
|
||||
cd "src/backend"
|
||||
uv lock
|
||||
cd -
|
||||
|
||||
# Update summary pyproject.toml
|
||||
update_python_version "summary"
|
||||
|
||||
@@ -149,6 +155,7 @@ echo " - src/frontend/package.json"
|
||||
echo " - src/sdk/package.json"
|
||||
echo " - src/mail/package.json"
|
||||
echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - CHANGELOG.md"
|
||||
|
||||
@@ -100,6 +100,13 @@ sequenceDiagram
|
||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
| **RECORDING_ENCODING_WIDTH** | Integer | `1280` | Recording video width in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_FRAMERATE** | Integer | `30` | Recording video framerate (fps). Directly impacts egress worker CPU (roughly linear). Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
|
||||
|
||||
### Manual Storage Webhook
|
||||
@@ -141,3 +148,54 @@ Using default project meet
|
||||
|
||||
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
|
||||
|
||||
## Tuning recording encoding
|
||||
|
||||
By default, LiveKit Egress records with the built-in `H264_720P_30` preset: 1280×720 at 30 fps, 3000 kbps H.264 MAIN video and 128 kbps AAC audio. For a one-hour meeting this produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing.
|
||||
|
||||
The `RECORDING_ENCODING_*` settings let operators override this preset without modifying the source. Values are passed straight through LiveKit's `EncodingOptions.advanced` to the GStreamer pipeline (`x264enc` for video, `faac` for audio), so there are no hidden conversions — what you set is what the encoder receives.
|
||||
|
||||
### How values map to GStreamer
|
||||
|
||||
| Setting | GStreamer element | Property |
|
||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
||||
|
||||
The H.264 profile is fixed to MAIN and the x264 `speed-preset` to `veryfast` by LiveKit (real-time constraint) — lowering the framerate is therefore the main lever to save CPU, while lowering the bitrate is the main lever to shrink the output file.
|
||||
|
||||
### Reference profiles
|
||||
|
||||
Rough 30-minute file-size estimates assume video + audio bitrate multiplied by duration. Actual sizes vary with content (static talking heads compress better than heavy screen motion). Egress CPU figures are indicative, measured on a single Ryzen laptop core saturated by the default preset (= 100 %); scaling is roughly linear with `framerate × bitrate` but the absolute numbers depend on the host hardware.
|
||||
|
||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
||||
| Minimum CPU | 960×540 | 15 | 500 | 64 | 4 | ~125 MB | ~30 % | Voice-first meetings, readable text not required |
|
||||
| Audio-heavy fallback | 1280×720 | 10 | 400 | 96 | 4 | ~110 MB | ~35 % | Long webinars, low motion |
|
||||
|
||||
★ Recommended starting point for typical LaSuite Meet usage.
|
||||
|
||||
Environment variables for the **Low CPU / small file** profile:
|
||||
|
||||
```bash
|
||||
RECORDING_ENCODING_ENABLED=True
|
||||
RECORDING_ENCODING_WIDTH=1280
|
||||
RECORDING_ENCODING_HEIGHT=720
|
||||
RECORDING_ENCODING_FRAMERATE=15
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
```
|
||||
|
||||
### Caveats
|
||||
|
||||
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The recommended preset (600 kbps × 15 fps) sits at exactly that threshold, comfortable for talking heads with occasional slide sharing. The same 600 kbps at 30 fps would only deliver 20 kbits/frame and visibly blur dense slides — which is why **lowering framerate is a more screen-share-friendly lever than lowering bitrate**. For deck-heavy or IDE-share meetings, prefer the **Slide-heavy** profile (900 kbps × 15 fps ≈ 60 kbits/frame).
|
||||
- **Motion handling**: the `veryfast` x264 preset is set by LiveKit and cannot be overridden here. Low-bitrate settings will therefore show more artefacts on fast motion than an offline re-encode with a slower preset would. This is the other reason FPS reduction is the safer tuning lever for meeting recordings.
|
||||
- **Audio**: AAC at 64 kbps stereo is transparent for voice but starts to compress music noticeably. Keep 128 kbps if you expect music playback in meetings.
|
||||
- **Codec choice**: H.264 MAIN is hardcoded on purpose. Switching to HEVC or VP9 would increase egress CPU cost 2×–5×, defeating the goal of this tuning.
|
||||
|
||||
@@ -68,6 +68,18 @@ SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
||||
# file size and CPU load on the egress worker.
|
||||
# RECORDING_ENCODING_ENABLED=False
|
||||
# RECORDING_ENCODING_WIDTH=1280
|
||||
# RECORDING_ENCODING_HEIGHT=720
|
||||
# RECORDING_ENCODING_FRAMERATE=30
|
||||
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000
|
||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
|
||||
Generated
+23
-27
@@ -36,7 +36,7 @@
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.1.0"
|
||||
"webpack-dev-server": "5.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
@@ -8996,23 +8996,6 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
|
||||
@@ -15671,15 +15654,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.1.0.tgz",
|
||||
"integrity": "sha512-aQpaN81X6tXie1FoOB7xlMfCsN19pSvRAeYUHOdFWOlhpQ/LlbfTqYwwmEDFV0h8GGuqmCmKmT+pxcUV/Nt2gQ==",
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.1.tgz",
|
||||
"integrity": "sha512-ml/0HIj9NLpVKOMq+SuBPLHcmbG+TGIjXRHsYfZwocUBIqEvws8NnS/V9AFQ5FKP+tgn5adwVwRrTEpGL33QFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.13",
|
||||
"@types/connect-history-api-fallback": "^1.5.4",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-serve-static-core": "^4.17.21",
|
||||
"@types/serve-index": "^1.9.4",
|
||||
"@types/serve-static": "^1.15.5",
|
||||
"@types/sockjs": "^0.3.36",
|
||||
@@ -15690,10 +15674,9 @@
|
||||
"colorette": "^2.0.10",
|
||||
"compression": "^1.7.4",
|
||||
"connect-history-api-fallback": "^2.0.0",
|
||||
"express": "^4.19.2",
|
||||
"express": "^4.21.2",
|
||||
"graceful-fs": "^4.2.6",
|
||||
"html-entities": "^2.4.0",
|
||||
"http-proxy-middleware": "^2.0.3",
|
||||
"http-proxy-middleware": "^2.0.7",
|
||||
"ipaddr.js": "^2.1.0",
|
||||
"launch-editor": "^2.6.1",
|
||||
"open": "^10.0.3",
|
||||
@@ -15728,6 +15711,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/define-lazy-prop": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||
@@ -15771,9 +15767,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"webpack-dev-server": "5.1.0"
|
||||
"webpack-dev-server": "5.2.1"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
"browserslist": [
|
||||
|
||||
@@ -177,7 +177,7 @@ class MetadataCollector:
|
||||
|
||||
def save(self):
|
||||
"""Serialize collected events and upload as JSON to S3."""
|
||||
logger.info("Persisting metadata…")
|
||||
logger.info("Persisting metadata...")
|
||||
|
||||
participants = []
|
||||
for k, v in self.participants.items():
|
||||
@@ -372,7 +372,7 @@ async def entrypoint(ctx: JobContext):
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
async def cleanup():
|
||||
logger.info("Shutting down metadata collector…")
|
||||
logger.info("Shutting down metadata collector...")
|
||||
await metadata_collector.aclose()
|
||||
|
||||
ctx.add_shutdown_callback(cleanup)
|
||||
|
||||
@@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
@@ -131,6 +132,16 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def validate_configuration(self, value):
|
||||
"""Validate room configuration against the RoomConfiguration schema."""
|
||||
if value is None or value == {}:
|
||||
return value
|
||||
try:
|
||||
RoomConfiguration.model_validate(value)
|
||||
except PydanticValidationError as e:
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
Add users only for administrator users.
|
||||
@@ -306,6 +317,22 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
RoomConfigurationTrackSource = Literal[
|
||||
"camera", "microphone", "screen_share", "screen_share_audio"
|
||||
]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
"""Validate room configuration structure.
|
||||
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ from logging import getLogger
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -320,16 +322,27 @@ class RoomViewSet(
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
recording = models.Recording.objects.create(
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
)
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
# IntegrityError covers the race where two concurrent requests both
|
||||
# pass that check and the DB-level UNIQUE constraint catches the loser.
|
||||
return drf_response.Response(
|
||||
{"error": f"A recording is already in progress for room {room.slug}"},
|
||||
status=drf_status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
@@ -337,9 +350,12 @@ class RoomViewSet(
|
||||
try:
|
||||
worker_manager.start(recording)
|
||||
except RecordingStartError:
|
||||
models.Recording.objects.filter(pk=recording.pk).update(
|
||||
status=models.RecordingStatusChoices.FAILED_TO_START
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to start for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
status=drf_status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and (
|
||||
@@ -347,6 +363,7 @@ class RoomViewSet(
|
||||
):
|
||||
try:
|
||||
MetadataCollectorService().start(recording)
|
||||
logger.debug("Started MetadataCollectorService")
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to start MetadataCollectorService")
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
@@ -9,9 +11,12 @@ from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -131,7 +136,50 @@ class NotificationService:
|
||||
return not has_failures
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
async def _get_recording_timestamps(worker_id):
|
||||
"""Fetch FileInfo.started_at and ended_at from LiveKit's egress API.
|
||||
|
||||
FileInfo.started_at is more accurate than EgressInfo.started_at because
|
||||
it reflects when file recording actually began. The started_at value exposed
|
||||
in the manifest file, as well as in the EgressInfo returned by the API,
|
||||
corresponds to when the egress service received the request, not the moment
|
||||
the egress worker effectively joined the room.
|
||||
|
||||
Returns:
|
||||
Tuple of (started_at, ended_at) datetimes, either may be None.
|
||||
"""
|
||||
|
||||
if not worker_id:
|
||||
return None, None
|
||||
|
||||
custom_configuration = {
|
||||
**settings.LIVEKIT_CONFIGURATION,
|
||||
"timeout": aiohttp.ClientTimeout(total=10),
|
||||
}
|
||||
lkapi = utils.create_livekit_client(custom_configuration=custom_configuration)
|
||||
try:
|
||||
egress_list = await lkapi.egress.list_egress(
|
||||
livekit_api.ListEgressRequest(egress_id=worker_id) # pylint: disable=no-member
|
||||
)
|
||||
except (livekit_api.TwirpError, OSError, asyncio.TimeoutError):
|
||||
logger.exception("Could not fetch egress info for worker %s", worker_id)
|
||||
return None, None
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
if not egress_list.items or not egress_list.items[0].file_results:
|
||||
logger.debug("No file_results for worker %s", worker_id)
|
||||
return None, None
|
||||
|
||||
file_result = egress_list.items[0].file_results[0]
|
||||
|
||||
def _ns_to_utc(ns):
|
||||
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc) if ns else None
|
||||
|
||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
@@ -150,24 +198,35 @@ class NotificationService:
|
||||
.first()
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
else:
|
||||
metadata_filename = None
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
started_at, ended_at = async_to_sync(
|
||||
NotificationService._get_recording_timestamps
|
||||
)(recording.worker_id)
|
||||
|
||||
payload = {
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"filename": recording.key,
|
||||
"recording_filename": recording.key,
|
||||
"metadata_filename": metadata_filename,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%H:%M"),
|
||||
"owner_timezone": str(owner_access.user.timezone),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": owner_access.user.language,
|
||||
"recording_start_at": (started_at.isoformat() if started_at else None),
|
||||
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
||||
}
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Factory, configurations and Protocol to create worker services"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
@@ -8,8 +10,17 @@ from typing import Any, ClassVar, Dict, Optional, Protocol, Type
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from livekit import api as livekit_api
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Codec / frequency constants matching LiveKit's H264_720P_30 preset.
|
||||
# Kept fixed because changing them would shift the goal-post away from the
|
||||
# "safe drop-in replacement for the default preset" contract of this feature.
|
||||
_RECORDING_VIDEO_CODEC = livekit_api.VideoCodec.H264_MAIN
|
||||
_RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerServiceConfig:
|
||||
@@ -18,6 +29,7 @@ class WorkerServiceConfig:
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
bucket_args: Optional[dict]
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
@@ -25,6 +37,24 @@ class WorkerServiceConfig:
|
||||
"""Load configuration from Django settings with caching for efficiency."""
|
||||
|
||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||
|
||||
encoding_options: Optional[Dict[str, Any]] = None
|
||||
if settings.RECORDING_ENCODING_ENABLED:
|
||||
# Single source of truth for the EncodingOptions kwargs:
|
||||
# operator-tunable values live in Django settings, codec / frequency
|
||||
# are pinned constants. The services layer only unpacks this dict.
|
||||
encoding_options = {
|
||||
"width": settings.RECORDING_ENCODING_WIDTH,
|
||||
"height": settings.RECORDING_ENCODING_HEIGHT,
|
||||
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
|
||||
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
|
||||
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||
"video_codec": _RECORDING_VIDEO_CODEC,
|
||||
"audio_codec": _RECORDING_AUDIO_CODEC,
|
||||
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
|
||||
}
|
||||
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
||||
@@ -36,6 +66,7 @@ class WorkerServiceConfig:
|
||||
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"force_path_style": True,
|
||||
},
|
||||
encoding_options=encoding_options,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,22 @@ class BaseEgressService:
|
||||
"""
|
||||
raise NotImplementedError("Subclass must implement this method.")
|
||||
|
||||
def _build_encoding_options(self):
|
||||
"""Build a LiveKit EncodingOptions from the service config, or None.
|
||||
|
||||
When None is returned, the caller should omit the `advanced` field so
|
||||
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
||||
|
||||
The full EncodingOptions kwargs (operator-tunable values + pinned
|
||||
codec / frequency constants) are assembled in `WorkerServiceConfig`,
|
||||
so this method is a thin protobuf adapter.
|
||||
"""
|
||||
opts = self._config.encoding_options
|
||||
if not opts:
|
||||
return None
|
||||
|
||||
return livekit_api.EncodingOptions(**opts)
|
||||
|
||||
|
||||
class VideoCompositeEgressService(BaseEgressService):
|
||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||
@@ -104,9 +120,17 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
s3=self._s3,
|
||||
)
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(
|
||||
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
|
||||
)
|
||||
request_kwargs = {
|
||||
"room_name": room_name,
|
||||
"file_outputs": [file_output],
|
||||
"layout": "speaker-light",
|
||||
}
|
||||
|
||||
advanced = self._build_encoding_options()
|
||||
if advanced is not None:
|
||||
request_kwargs["advanced"] = advanced
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(**request_kwargs)
|
||||
|
||||
response = self._handle_request(request, "start_room_composite_egress")
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ class LobbyService:
|
||||
|
||||
def request_entry(
|
||||
self,
|
||||
room,
|
||||
room: models.Room,
|
||||
request,
|
||||
username: str,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
|
||||
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc == "localhost:9000"
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test worker service factories.
|
||||
"""
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from unittest.mock import Mock
|
||||
@@ -10,6 +10,9 @@ from unittest.mock import Mock
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit import (
|
||||
api as livekit_api_codec,
|
||||
)
|
||||
|
||||
from core.recording.worker.factories import (
|
||||
WorkerService,
|
||||
@@ -63,6 +66,8 @@ def test_config_initialization(default_config):
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
}
|
||||
# Encoding override is opt-in; disabled by default.
|
||||
assert default_config.encoding_options is None
|
||||
|
||||
|
||||
def test_config_immutability(default_config):
|
||||
@@ -71,6 +76,45 @@ def test_config_immutability(default_config):
|
||||
default_config.output_folder = "new/path"
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
AWS_S3_REGION_NAME="test-region",
|
||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||
RECORDING_ENCODING_ENABLED=True,
|
||||
RECORDING_ENCODING_WIDTH=1280,
|
||||
RECORDING_ENCODING_HEIGHT=720,
|
||||
RECORDING_ENCODING_FRAMERATE=15,
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||
)
|
||||
def test_config_encoding_options_enabled():
|
||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
||||
|
||||
The dict mixes operator-tunable values from settings with pinned codec /
|
||||
frequency constants, so the services layer can simply unpack it.
|
||||
"""
|
||||
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
|
||||
assert config.encoding_options == {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 15,
|
||||
"video_bitrate": 600,
|
||||
"audio_bitrate": 64,
|
||||
"key_frame_interval": 10.0,
|
||||
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
|
||||
@@ -39,6 +39,31 @@ def config():
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_with_encoding(config):
|
||||
"""Fixture for a config carrying custom encoding options.
|
||||
|
||||
Mirrors the dict shape produced by `WorkerServiceConfig.from_settings()`
|
||||
(operator-tunable values + pinned codec / frequency constants).
|
||||
"""
|
||||
return WorkerServiceConfig(
|
||||
output_folder=config.output_folder,
|
||||
server_configurations=config.server_configurations,
|
||||
bucket_args=config.bucket_args,
|
||||
encoding_options={
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"framerate": 15,
|
||||
"video_bitrate": 600,
|
||||
"audio_bitrate": 64,
|
||||
"key_frame_interval": 10.0,
|
||||
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||
"audio_frequency": 48000,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_upload():
|
||||
"""Fixture for mocked S3Upload"""
|
||||
@@ -224,6 +249,41 @@ def test_video_composite_egress_start_missing_egress_id(video_service):
|
||||
assert "Egress ID not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_video_composite_egress_start_without_encoding_options(video_service):
|
||||
"""When no encoding options are configured, no `advanced` field is set.
|
||||
|
||||
LiveKit then falls back to its built-in preset (H264_720P_30).
|
||||
"""
|
||||
video_service._handle_request.return_value = Mock(egress_id="eg-1")
|
||||
video_service.start("test-room", "rec-1")
|
||||
|
||||
request = video_service._handle_request.call_args[0][0]
|
||||
# Proto oneof `options` must be unset when no advanced encoding is provided.
|
||||
assert request.WhichOneof("options") is None
|
||||
|
||||
|
||||
def test_video_composite_egress_start_with_encoding_options(config_with_encoding):
|
||||
"""Custom encoding options are forwarded as `advanced` EncodingOptions."""
|
||||
service = VideoCompositeEgressService(config_with_encoding)
|
||||
service._handle_request = Mock(return_value=Mock(egress_id="eg-2"))
|
||||
|
||||
service.start("test-room", "rec-2")
|
||||
|
||||
request = service._handle_request.call_args[0][0]
|
||||
assert request.WhichOneof("options") == "advanced"
|
||||
|
||||
advanced = request.advanced
|
||||
assert advanced.width == 1280
|
||||
assert advanced.height == 720
|
||||
assert advanced.framerate == 15
|
||||
assert advanced.video_bitrate == 600
|
||||
assert advanced.audio_bitrate == 64
|
||||
assert advanced.key_frame_interval == pytest.approx(10.0)
|
||||
assert advanced.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||
assert advanced.audio_codec == livekit_api.AudioCodec.AAC
|
||||
assert advanced.audio_frequency == 48000
|
||||
|
||||
|
||||
def test_audio_composite_egress_hrid(audio_service):
|
||||
"""Test HRID is correct"""
|
||||
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
|
||||
|
||||
@@ -232,7 +232,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"""
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC,
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
@@ -264,7 +264,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
@@ -363,7 +363,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory(
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
@@ -401,7 +401,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -140,16 +140,18 @@ def test_start_recording_worker_error(
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to start for room {room.slug}"
|
||||
}
|
||||
|
||||
# Recording object should be created even if worker fails
|
||||
# Recording object should be created even if worker fails, and moved out
|
||||
# of the unique-constraint window so the room is not locked.
|
||||
assert Recording.objects.count() == 1
|
||||
recording = Recording.objects.first()
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
assert recording.status == "failed_to_start"
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
@@ -158,6 +160,72 @@ def test_start_recording_worker_error(
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["active", "initiated"],
|
||||
)
|
||||
def test_start_recording_conflict_when_already_in_progress(
|
||||
status, mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should return 409 when a second start is attempted while a recording is already active."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
# Pre-existing active recording for the same room.
|
||||
Recording.objects.create(room=room, mode="screen_recording", status="active")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert response.json() == {
|
||||
"error": f"A recording is already in progress for room {room.slug}"
|
||||
}
|
||||
# No new recording row, no access row leaked from the rolled-back transaction.
|
||||
assert Recording.objects.count() == 1
|
||||
assert Recording.objects.first().accesses.count() == 0
|
||||
mock_worker_manager.start.assert_not_called()
|
||||
|
||||
|
||||
def test_start_recording_after_worker_failure_unblocks_room(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should allow a new recording when the previous recording failed."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
mock_worker_manager.start = mock.Mock(
|
||||
side_effect=[RecordingStartError("boom"), None]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
first = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert first.status_code == 502
|
||||
|
||||
second = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
assert second.status_code == 201
|
||||
assert Recording.objects.count() == 2
|
||||
|
||||
|
||||
def test_start_recording_success(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_api_rooms_update_members():
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -95,7 +95,7 @@ def test_api_rooms_update_administrators():
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
@@ -104,7 +104,98 @@ def test_api_rooms_update_administrators():
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"the_key": "the_value"}
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration",
|
||||
[
|
||||
{},
|
||||
{"can_publish_sources": ["camera", "microphone"]},
|
||||
{
|
||||
"can_publish_sources": [
|
||||
"camera",
|
||||
"microphone",
|
||||
"screen_share",
|
||||
"screen_share_audio",
|
||||
]
|
||||
},
|
||||
{"can_publish_sources": []},
|
||||
{"can_publish_sources": None},
|
||||
],
|
||||
)
|
||||
def test_api_rooms_update_configuration_valid(configuration):
|
||||
"""Administrators should be allowed to set valid configurations."""
|
||||
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": configuration},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == configuration
|
||||
|
||||
|
||||
def test_api_rooms_update_configuration_extra_keys_rejected():
|
||||
"""Extra keys in configuration should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"configuration": {
|
||||
"can_publish_sources": ["camera"],
|
||||
"arbitrary_key": "value",
|
||||
}
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_source", ["invalid_source", "CAMERA"])
|
||||
def test_api_rooms_update_configuration_invalid_source_value(invalid_source):
|
||||
"""Invalid source values should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": [invalid_source]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_configuration_wrong_type():
|
||||
"""Configuration values with wrong types should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": "camera"}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators_of_another():
|
||||
|
||||
@@ -576,8 +576,8 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -572,8 +572,8 @@ msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Click the \"Open\" button below "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -578,8 +578,8 @@ msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -571,8 +571,8 @@ msgstr "Om deze opname permanent te bewaren:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klik op de \"Openen\"-knop hieronder "
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
|
||||
@@ -700,6 +700,44 @@ class Base(Configuration):
|
||||
RECORDING_MAX_DURATION = values.IntegerValue(
|
||||
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||
)
|
||||
|
||||
# Recording encoding options for LiveKit Egress (video composite egress only).
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
# recordings), whose request never carries advanced EncodingOptions.
|
||||
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset
|
||||
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
|
||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
||||
# output file size and CPU load on the egress worker.
|
||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
||||
)
|
||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
3000,
|
||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||
128,
|
||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
|
||||
4.0,
|
||||
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
@@ -817,6 +855,11 @@ class Base(Configuration):
|
||||
environ_name="METADATA_COLLECTOR_AGENT_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
METADATA_COLLECTOR_OUTPUT_FOLDER = values.Value(
|
||||
"metadata",
|
||||
environ_name="METADATA_COLLECTOR_OUTPUT_FOLDER",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# External Applications
|
||||
APPLICATION_ENABLED = values.BooleanValue(
|
||||
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.13",
|
||||
"django==5.2.14",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
|
||||
Generated
+4
-4
@@ -573,16 +573,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.13"
|
||||
version = "5.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1243,7 +1243,7 @@ requires-dist = [
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" },
|
||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||
{ name = "django", specifier = "==5.2.13" },
|
||||
{ name = "django", specifier = "==5.2.14" },
|
||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==8.2.0" },
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
declare module '@fontsource-variable/lexend' {}
|
||||
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
|
||||
declare module '@fontsource/opendyslexic' {}
|
||||
+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
+150
-4
@@ -8,8 +8,11 @@
|
||||
"name": "meet",
|
||||
"version": "1.15.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.0",
|
||||
@@ -54,7 +57,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.6",
|
||||
"postcss": "8.5.10",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
@@ -1025,6 +1028,24 @@
|
||||
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
|
||||
"integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/lexend": {
|
||||
"version": "5.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/lexend/-/lexend-5.2.11.tgz",
|
||||
"integrity": "sha512-0hgEQ4O7Nh8fxL/WWmspJf0BErbocRkZwtLRGey/V4mUUqxfF7QUwqhcdzwpjom3NYCniY4uzQ5wYD7r9/92tQ==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/material-symbols-outlined": {
|
||||
"version": "5.2.34",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.34.tgz",
|
||||
@@ -1043,6 +1064,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/opendyslexic": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/opendyslexic/-/opendyslexic-5.2.5.tgz",
|
||||
"integrity": "sha512-NNS9aaPQx2TlaTvb3vTEjw3xz8lKj23mBc+6rM00mSNFDygdoll0/nLMHFtDKKrBT6sMfY6TFFPOR0D9ktdspg==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
|
||||
@@ -1759,6 +1789,35 @@
|
||||
"ts-pattern": "5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/core/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/dev": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/dev/-/dev-1.8.2.tgz",
|
||||
@@ -1816,6 +1875,35 @@
|
||||
"ts-pattern": "5.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/generator/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/is-valid-prop": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.8.2.tgz",
|
||||
@@ -1917,6 +2005,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/node/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/node/node_modules/prettier": {
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
@@ -1976,6 +2093,35 @@
|
||||
"postcss": "8.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/postcss/node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/preset-base": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/preset-base/-/preset-base-1.8.2.tgz",
|
||||
@@ -9432,9 +9578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.0",
|
||||
@@ -59,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.6",
|
||||
"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 |
@@ -14,12 +14,14 @@ import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
import { useApplyA11yFonts } from '@/hooks/useApplyA11yFonts'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useTitle(import.meta.env.VITE_APP_TITLE ?? '')
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
useApplyA11yFonts()
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -1,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,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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,6 +34,9 @@ import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||
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'
|
||||
@@ -227,6 +230,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -248,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
|
||||
@@ -289,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',
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
// Google Meet uses only letters in a room identifier
|
||||
const ROOM_ID_ALLOWED_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
const getRandomChar = () =>
|
||||
ROOM_ID_ALLOWED_CHARACTERS[
|
||||
Math.floor(Math.random() * ROOM_ID_ALLOWED_CHARACTERS.length)
|
||||
const getRandomChar = () => {
|
||||
const maxValue =
|
||||
Math.floor(0x100000000 / ROOM_ID_ALLOWED_CHARACTERS.length) *
|
||||
ROOM_ID_ALLOWED_CHARACTERS.length
|
||||
const randomValue = new Uint32Array(1)
|
||||
|
||||
do {
|
||||
crypto.getRandomValues(randomValue)
|
||||
} while (randomValue[0] >= maxValue)
|
||||
|
||||
return ROOM_ID_ALLOWED_CHARACTERS[
|
||||
randomValue[0] % ROOM_ID_ALLOWED_CHARACTERS.length
|
||||
]
|
||||
}
|
||||
|
||||
const generateSegment = (length: number): string =>
|
||||
Array.from(Array(length), getRandomChar).join('')
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Field, H } from '@/primitives'
|
||||
import { Field, H, Text } from '@/primitives'
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
import {
|
||||
accessibilityStore,
|
||||
UI_FONT_OPTIONS,
|
||||
type UiFont,
|
||||
} from '@/stores/accessibility'
|
||||
import { CaptionsSettings } from '@/features/subtitle/component/CaptionsSettings'
|
||||
|
||||
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
|
||||
@@ -12,6 +17,15 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const snap = useSnapshot(accessibilityStore)
|
||||
|
||||
const fontItems = useMemo(
|
||||
() =>
|
||||
UI_FONT_OPTIONS.map((font) => ({
|
||||
value: font,
|
||||
label: t(`accessibility.font.options.${font}`),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('tabs.accessibility')}</H>
|
||||
@@ -33,6 +47,21 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('accessibility.font.label')}
|
||||
items={fontItems}
|
||||
selectedKey={snap.uiFont}
|
||||
onSelectionChange={(key) => {
|
||||
accessibilityStore.uiFont = key as UiFont
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
<Text variant="smNote" className={css({ marginTop: '0.25rem' })}>
|
||||
{t('accessibility.font.description')}
|
||||
</Text>
|
||||
</li>
|
||||
<CaptionsSettings />
|
||||
</ul>
|
||||
</TabPanel>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessibilityStore, UiFont } from '@/stores/accessibility'
|
||||
|
||||
const fontImports: Partial<Record<UiFont, () => Promise<unknown>>> = {
|
||||
lexend: () => import('@fontsource-variable/lexend'),
|
||||
'atkinson-hyperlegible': () =>
|
||||
import('@fontsource-variable/atkinson-hyperlegible-next'),
|
||||
opendyslexic: () => import('@fontsource/opendyslexic'),
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<UiFont>()
|
||||
|
||||
export function useApplyA11yFonts() {
|
||||
const { uiFont } = useSnapshot(accessibilityStore)
|
||||
|
||||
useEffect(() => {
|
||||
if (uiFont === 'default') {
|
||||
return
|
||||
}
|
||||
|
||||
const className = `font-${uiFont}`
|
||||
const loader = fontImports[uiFont]
|
||||
|
||||
if (loader && !loadedFonts.has(uiFont)) {
|
||||
loader().then(() => {
|
||||
loadedFonts.add(uiFont)
|
||||
document.documentElement.classList.add(className)
|
||||
})
|
||||
} else {
|
||||
document.documentElement.classList.add(className)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.documentElement.classList.remove(className)
|
||||
}
|
||||
}, [uiFont])
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Aufzeichnung nicht verfügbar",
|
||||
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur die*der Organisator:in der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
|
||||
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Host der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Aufzeichnung abgelaufen",
|
||||
@@ -22,7 +22,7 @@
|
||||
"button": "Herunterladen",
|
||||
"warning": {
|
||||
"title": "Linkfreigabe deaktiviert",
|
||||
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisator:innen können sie herunterladen."
|
||||
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Hosts können sie herunterladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"heading": {
|
||||
"normal": "Du hast das Meeting verlassen",
|
||||
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
|
||||
"participantRemoved": "Du wurdest von der*dem Organisator:in aus dem Meeting entfernt"
|
||||
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt"
|
||||
},
|
||||
"home": "Zur Startseite zurückkehren",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
@@ -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"
|
||||
@@ -383,7 +405,7 @@
|
||||
"body": "Transkribiere bis zu {{max_duration}} Meetingdauer.",
|
||||
"bodyWithoutMaxDuration": "Transkribieren dein Meeting ohne Zeitbegrenzung.",
|
||||
"details": {
|
||||
"receiver": "Das Transkript wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
|
||||
"receiver": "Das Transkript wird an den Host und die Co-Hosts gesendet.",
|
||||
"destination": "Ein neues Dokument wird erstellt auf",
|
||||
"destinationUnknown": "Ein neues Dokument wird erstellt",
|
||||
"language": "Meeting-Sprache:",
|
||||
@@ -438,7 +460,7 @@
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zur Aufzeichnung öffnen – öffnet in neuem Tab",
|
||||
"details": {
|
||||
"receiver": "Die Aufzeichnung wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
|
||||
"receiver": "Die Aufzeichnung wird an den Host und die Co-Hosts gesendet.",
|
||||
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
|
||||
"loading": "Aufzeichnung wird gestartet",
|
||||
"linkMore": "Mehr erfahren",
|
||||
@@ -494,7 +516,7 @@
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Diese Einstellungen für Organisierende ermöglichen dir die Kontrolle über dein Meeting. Nur Organisierende haben Zugriff auf diese Optionen.",
|
||||
"description": "Diese Host-Einstellungen ermöglichen dir die Kontrolle über dein Meeting. Nur Hosts haben Zugriff auf diese Optionen.",
|
||||
"access": {
|
||||
"title": "Raumzugang",
|
||||
"description": "Diese Einstellungen gelten auch für zukünftige Meetings in diesem Raum.",
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
"announceReactions": {
|
||||
"label": "Reaktionen vorlesen"
|
||||
},
|
||||
"font": {
|
||||
"label": "Anzeigeschrift",
|
||||
"description": "Passe die in der Oberfläche verwendete Schrift für ein besseres Leseerlebnis an.",
|
||||
"options": {
|
||||
"default": "Standard (System)",
|
||||
"lexend": "Lexend (verbesserte Lesbarkeit)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (Sehbeeinträchtigung)",
|
||||
"opendyslexic": "OpenDyslexic (Dyslexie)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Untertitel",
|
||||
"textSize": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Recording unavailable",
|
||||
"body": "The recording is unavailable or may have been deleted. Only the meeting organizer can access it. Feel free to contact them if needed."
|
||||
"body": "The recording is unavailable or may have been deleted. Only the meeting host can access it. Feel free to contact them if needed."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Recording expired",
|
||||
@@ -22,7 +22,7 @@
|
||||
"button": "Download",
|
||||
"warning": {
|
||||
"title": "Link sharing disabled",
|
||||
"body": "Sharing the recording via link is not yet available. Only organizers can download it."
|
||||
"body": "Sharing the recording via link is not yet available. Only hosts can download it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"heading": {
|
||||
"normal": "You have left the meeting",
|
||||
"duplicateIdentity": "You have joined the meeting from another device",
|
||||
"participantRemoved": "You have been removed from the meeting by an administrator"
|
||||
"participantRemoved": "You have been removed from the meeting by a host"
|
||||
},
|
||||
"home": "Return to home",
|
||||
"back": "Rejoin the meeting"
|
||||
@@ -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"
|
||||
@@ -328,7 +350,7 @@
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "Transcribe",
|
||||
"screenRecording": "Record",
|
||||
"admin": "Admin settings",
|
||||
"admin": "Host settings",
|
||||
"tools": "More tools",
|
||||
"info": "Meeting information"
|
||||
},
|
||||
@@ -338,7 +360,7 @@
|
||||
"chat": "messages",
|
||||
"transcript": "transcribe",
|
||||
"screenRecording": "record",
|
||||
"admin": "admin settings",
|
||||
"admin": "host settings",
|
||||
"tools": "more tools",
|
||||
"info": "meeting information"
|
||||
},
|
||||
@@ -493,7 +515,7 @@
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
|
||||
"description": "These host settings allow you to maintain control of your meeting. Only hosts can access these controls.",
|
||||
"access": {
|
||||
"title": "Room access",
|
||||
"description": "These settings will also apply to future occurrences of this meeting.",
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
"announceReactions": {
|
||||
"label": "Announce reactions aloud"
|
||||
},
|
||||
"font": {
|
||||
"label": "Display font",
|
||||
"description": "Customize the font used in the interface to improve your reading comfort.",
|
||||
"options": {
|
||||
"default": "Default",
|
||||
"lexend": "Lexend (improved readability)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (low vision)",
|
||||
"opendyslexic": "OpenDyslexic (dyslexia)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Captions",
|
||||
"textSize": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
"announceReactions": {
|
||||
"label": "Vocaliser les réactions"
|
||||
},
|
||||
"font": {
|
||||
"label": "Police d'affichage",
|
||||
"description": "Personnalisez la police utilisée dans l'interface pour améliorer votre confort de lecture.",
|
||||
"options": {
|
||||
"default": "Par défaut",
|
||||
"lexend": "Lexend (lisibilité améliorée)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (basse vision)",
|
||||
"opendyslexic": "OpenDyslexic (dyslexie)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Sous-titres",
|
||||
"textSize": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Opname niet beschikbaar",
|
||||
"body": "De opname is niet beschikbaar of is mogelijk verwijderd. Alleen de organisator van de vergadering heeft toegang. Neem gerust contact met hem of haar op als dat nodig is."
|
||||
"body": "De opname is niet beschikbaar of is mogelijk verwijderd. Alleen de host van de vergadering heeft toegang. Neem gerust contact met hem of haar op als dat nodig is."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Opname verlopen",
|
||||
@@ -22,7 +22,7 @@
|
||||
"button": "Downloaden",
|
||||
"warning": {
|
||||
"title": "Delen via link uitgeschakeld",
|
||||
"body": "Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
|
||||
"body": "Het delen van de opname via een link is nog niet beschikbaar. Alleen hosts kunnen deze downloaden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -437,7 +459,7 @@
|
||||
"linkMore": "Documentatie openen",
|
||||
"linkAriaLabel": "Documentatie over opname openen - opent in nieuw venster",
|
||||
"details": {
|
||||
"receiver": "De opname wordt verzonden naar de organisator en co-organisatoren.",
|
||||
"receiver": "De opname wordt verzonden naar de host en co-hosts.",
|
||||
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
|
||||
"loading": "Opname wordt gestart",
|
||||
"linkMore": "Meer informatie",
|
||||
@@ -493,7 +515,7 @@
|
||||
"button": "Opnieuw proberen"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Deze organisatorinstellingen geven u controle over uw vergadering. Alleen organisatoren hebben toegang tot deze bedieningselementen.",
|
||||
"description": "Deze hostinstellingen geven u controle over uw vergadering. Alleen hosts hebben toegang tot deze bedieningselementen.",
|
||||
"access": {
|
||||
"title": "Toegang tot vergadering",
|
||||
"description": "Deze instellingen zijn ook van toepassing op toekomstige sessies van deze vergadering.",
|
||||
|
||||
@@ -117,6 +117,16 @@
|
||||
"announceReactions": {
|
||||
"label": "Reacties hardop aankondigen"
|
||||
},
|
||||
"font": {
|
||||
"label": "Weergavelettertype",
|
||||
"description": "Pas het lettertype van de interface aan om uw leescomfort te verbeteren.",
|
||||
"options": {
|
||||
"default": "Standaard",
|
||||
"lexend": "Lexend (verbeterde leesbaarheid)",
|
||||
"atkinson-hyperlegible": "Atkinson Hyperlegible (slechtziend)",
|
||||
"opendyslexic": "OpenDyslexic (dyslexie)"
|
||||
}
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Ondertitels",
|
||||
"textSize": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,6 +2,19 @@ import { proxy, subscribe } from 'valtio'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
import { deserializeToProxyMap } from '@/utils/valtio'
|
||||
|
||||
export type UiFont =
|
||||
| 'default'
|
||||
| 'lexend'
|
||||
| 'atkinson-hyperlegible'
|
||||
| 'opendyslexic'
|
||||
|
||||
export const UI_FONT_OPTIONS: UiFont[] = [
|
||||
'default',
|
||||
'lexend',
|
||||
'atkinson-hyperlegible',
|
||||
'opendyslexic',
|
||||
]
|
||||
|
||||
export type CaptionTextSize = 'small' | 'medium' | 'large'
|
||||
|
||||
export const CAPTION_TEXT_SIZE_OPTIONS: CaptionTextSize[] = [
|
||||
@@ -62,6 +75,7 @@ type AccessibilityState = {
|
||||
captionTextSize: CaptionTextSize
|
||||
captionFontColor: CaptionColor
|
||||
captionBackgroundColor: CaptionColor
|
||||
uiFont: UiFont
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: AccessibilityState = {
|
||||
@@ -69,6 +83,7 @@ const DEFAULT_STATE: AccessibilityState = {
|
||||
captionTextSize: 'medium',
|
||||
captionFontColor: 'default',
|
||||
captionBackgroundColor: 'default',
|
||||
uiFont: 'default',
|
||||
}
|
||||
|
||||
function getAccessibilityState(): AccessibilityState {
|
||||
@@ -91,6 +106,9 @@ function getAccessibilityState(): AccessibilityState {
|
||||
)
|
||||
? parsed.captionBackgroundColor
|
||||
: DEFAULT_STATE.captionBackgroundColor
|
||||
const uiFont = UI_FONT_OPTIONS.includes(parsed.uiFont)
|
||||
? parsed.uiFont
|
||||
: DEFAULT_STATE.uiFont
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
@@ -101,6 +119,7 @@ function getAccessibilityState(): AccessibilityState {
|
||||
captionTextSize,
|
||||
captionFontColor,
|
||||
captionBackgroundColor,
|
||||
uiFont,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -6,6 +6,19 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html.font-lexend {
|
||||
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html.font-atkinson-hyperlegible {
|
||||
--fonts-sans:
|
||||
'Atkinson Hyperlegible Next Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html.font-opendyslexic {
|
||||
--fonts-sans: OpenDyslexic, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
@@ -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))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user