Compare commits

..

80 Commits

Author SHA1 Message Date
Cyril eaa3a2a284 fixup! ♻️ decouple PiP behavior from shared primitives 2026-05-12 09:40:13 +02:00
Cyril cdc5add2f3 ♻️(refactor) Share reactions toggle and paginated toolbar
Unify main and PiP emoji UI via context; remove PiP-only duplicates.
2026-05-12 09:31:24 +02:00
Cyril 8793226427 (frontend) add PiP placeholder on main window
Show placeholder when PiP is open; i18n and focus-visible on bring-back action.
2026-05-12 09:24:48 +02:00
Cyril 357a989b9d fixup! ♻️ decouple PiP behavior from shared primitives 2026-05-12 09:24:48 +02:00
Cyril b8f3c0aa59 ♻️(refactor) remove PiP subtitles controls and align PiP toast container
Drop subtitles actions from PiP controls and reuse main toast container style.
2026-05-12 09:24:48 +02:00
Cyril 37a3d6e7ed fixup! ♻️(frontend) harden PiP notifications stacking and dedupe 2026-05-12 09:24:47 +02:00
Cyril afa9d36329 fixup! ♻️(refactor) inject side panel store via context for PiP 2026-05-12 09:24:47 +02:00
Cyril 3d81110f56 ♻️(refactor) inject side panel store via context for PiP
Keep pipLayoutStore separate from layoutStore; shared components use context.
2026-05-12 09:24:46 +02:00
Cyril 5a508aeb31 ♻️ decouple PiP behavior from shared primitives
Remove PiP-specific branching from Menu/Tooltip primitives via explicit props and context.
2026-05-12 09:24:46 +02:00
Cyril f9ca489b2f fixup! ♻️(frontend) normalize PiP component patterns 2026-05-12 09:24:46 +02:00
Cyril 47ec78973b ♻️(frontend) clean Sonar warnings in PiP overlay primitives
Replace verbose checks and nested ternaries with clearer PiP-safe logic.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 09:24:45 +02:00
Cyril c239a7f2e5 💄(frontend) adjust PiP stage region horizontal spacing
Use left/right margins only on the PiP stage region container.
2026-05-12 09:24:45 +02:00
Cyril 87f8d4ca7e 🐛(frontend) route PiP tile effects to the PiP side panel
Inject side-panel store into PiP tiles so Effects opens in PiP window.
2026-05-12 09:24:44 +02:00
Cyril 45b939da5c ️(frontend) restore focus-visible rings inside PiP window
Sync React Aria interaction modality with PiP keyboard and mouse events.
2026-05-12 09:24:43 +02:00
Cyril 5a6654b3a8 🐛(frontend) restore participant metadata in PiP tiles
Show participant name and tile metadata in PiP layouts.
2026-05-12 09:24:42 +02:00
Cyril afaaec4be1 🐛(frontend) fix PiP reactions rendering and shortcut ownership
Show reactions in PiP and prevent main shortcuts from overriding PiP.
2026-05-12 09:24:42 +02:00
Cyril 184e957565 ♻️(frontend) fix VisualOnlyTooltip clipping in PiP
Flip when top overflows; clamp left/right so PiP tooltips stay visible.
2026-05-12 09:24:41 +02:00
Cyril 323a998721 ♻️(frontend) address Sonar warnings in PiP and side panel
Replace nested ternary logic, use globalThis instead of window, and switch side-panel enum re-exports to export-from for clearer and safer patterns.

Made-with: Cursor
2026-05-12 09:24:41 +02:00
Cyril 2b50dd3158 ♻️(frontend) normalize PiP component patterns
Extract OverflowItems, replace `any`, convert function declarations to const arrows.
2026-05-12 09:24:41 +02:00
Cyril 852789cec6 ♻️(frontend) harden PiP notifications stacking and dedupe
Group banner and toasts in one stack, cap visibility, fix chat dedup.
2026-05-12 09:24:40 +02:00
Cyril f38dec6a93 (frontend) show room notifications inside the PiP window
Render the shared toast queue in PiP.
2026-05-12 09:24:40 +02:00
Cyril d8548ec9bb (frontend) restore toolbar shortcuts inside PiP window
Forward PiP keydown events and register PiP-specific handlers.
2026-05-12 09:24:39 +02:00
Cyril 775c8ac073 🐛(frontend) close native PiP window when leaving the room
Force-close PiP on unmount and reset PiP state so next room starts clean.
2026-05-12 09:24:38 +02:00
Cyril 90ef4ce025 🐛(frontend) fix PiP tile corner clipping and unify 4px radius
Set a single clipping owner per PiP layout and use a consistent 4px radius.
2026-05-12 09:24:37 +02:00
Cyril 8b1d200740 ♻️(frontend) PiP options menu and reactions toolbar refactor
PiP-native options popover; split reactions, keyboard nav, and pagination.
2026-05-12 09:24:37 +02:00
Cyril f6510b7488 ️(frontend) PiP accessibility and cross-document focus foundation
Cross-doc focus helpers, Escape stack, lang/title sync, landmarks, SR announce.
2026-05-12 09:24:36 +02:00
Cyril 640205fd9e 🌐(i18n) add PiP accessibility strings to rooms
Labels for PiP window, stage, toolbar, reactions, and SR open/close.
2026-05-12 09:24:36 +02:00
Cyril 3c04d1e9a7 🎨(frontend) tune PiP window default dimensions
Start PiP in a taller shape closer to a typical video call layout.
2026-05-12 09:24:35 +02:00
Cyril bbc3d18309 (frontend) add adaptive PiP stage with focus and grid layouts
Render all participants with a focus mode and animated adaptive grid.
2026-05-12 09:24:35 +02:00
Cyril 61877d23bc ♻️(frontend) share a PiP-aware element size hook
Reuse a single ResizeObserver hook in the PiP control bar.
2026-05-12 09:24:34 +02:00
Cyril a6d20e2f58 (frontend) add PiP reactions toolbar and isolated state
PiP-only reactions strip, store flag, and overflow menu wired to PiP state.
2026-05-12 09:24:34 +02:00
Cyril 1e21274237 🐛(frontend) fix pip controls responsive overflow in realtime
Use PiP document ResizeObserver so controls hide into "..." while resizing.
2026-05-12 09:24:34 +02:00
Cyril 6b566831a2 🐛(frontend) align pip layout with google meet
Show a main tile with a bottom-right thumbnail in PiP, like Google Meet.
2026-05-12 09:24:32 +02:00
Cyril b57e11bf51 ♻️(frontend) decouple pip state and simplify pip options
Prevent PiP actions from affecting main panel state and reduce PiP-only noise.
2026-05-12 09:24:31 +02:00
Cyril 2378af9e9b (perf) hide main layout when pip is open
avoid rendering main video tiles while pip is active
2026-05-12 09:24:31 +02:00
Cyril 30107882dd ♻️(refactor) move pip feature into dedicated module
group pip components and hooks under features/pip for isolation
2026-05-12 09:24:30 +02:00
Cyril 4f25a1d983 (feat) move pip state to valtio store
replace context provider with shared store for pip ui state
2026-05-12 09:24:30 +02:00
Cyril 2c02a26563 🐛(frontend) clamp pip tooltips on right edge
prevent pip tooltips from overflowing the right side of the window
2026-05-12 09:24:29 +02:00
Cyril 3eb694b6dd fixup! 🌐(i18n) add PiP labels for each supported language 2026-05-12 09:24:29 +02:00
Cyril 9b2e7905fc fixup! (feat) connect PiP to room and options menu 2026-05-12 09:24:29 +02:00
Cyril e1c6fb10f3 fixup! 🐛(fix) add react-aria overlays type shim 2026-05-12 09:24:28 +02:00
Cyril 04ceb5d568 (feat) connect PiP to room and options menu
wire room-level PiP state to the options menu
2026-05-12 09:24:28 +02:00
Cyril 1771527c2d 💄(style) align pip layout with room styling
match pip background and spacing to
2026-05-12 09:24:27 +02:00
Cyril 63a949e420 💄(style) adjust tooltip positioning and fix ts error
raise tooltip offset and set arrowBoundaryOffset
2026-05-12 09:24:26 +02:00
Cyril 69135feba5 🐛(fix) add react-aria overlays type shim
fix missing useUNSAFE_PortalContext export typing
2026-05-12 09:24:26 +02:00
Cyril 57a27de8e7 (feat) add pip lateral menu component
add pip chevron menu
2026-05-12 09:24:25 +02:00
Cyril 09bb4daebc 📝(docs) add pip component documentation
add jsdoc comments to pip components and hooks
2026-05-12 09:24:25 +02:00
Cyril 3e3d9f31a5 🐛(fix) close pip menu on item action
close options menu when selecting transcription or
2026-05-12 09:24:24 +02:00
Cyril 439f7b3746 ♻️(refactor) extract pip control bar component
centralize pip controls in reusable component
2026-05-12 09:24:23 +02:00
Cyril fa6967635d ♻️(feat) extract pip options menu component
keep pip options layout reusable and isolated
2026-05-12 09:24:23 +02:00
Cyril 07e172041f 💄(fix) align overlays in pip
route menus/tooltips to the pip document
2026-05-12 09:24:22 +02:00
Cyril 8c93d26d48 (refactor) stabilize pip layout and controls
avoid layout jumps and prepare for more buttons
2026-05-12 09:24:22 +02:00
Cyril a2c55f2305 🐛(fix) rebuild pip root on reopen
prevent black screen after closing and reopening pip
2026-05-12 09:24:22 +02:00
Cyril 2f1d102b44 🌐(i18n) add PiP labels for each supported language
add labels for PiP in each supported language
2026-05-12 09:24:21 +02:00
Cyril e9634b7610 (feat) connect PiP to room and options menu
connecting pip and room
2026-05-12 09:24:20 +02:00
Cyril 4697cbc875 (feat) add pip portal and base layout
mount react content into pip window with styles
2026-05-12 09:24:20 +02:00
Cyril 8c3fb63724 (feat) add document pip hook
utility hook to cleanly open/close the pip window
2026-05-12 09:24:14 +02:00
UGilfoyle 964b3cd452 🐛(backend) add link to "Open" text in recording email
Added a hyperlink to the "Open" text in step 1 of the recording
notification email instructions. Previously, "Open" was plain text
and users could only access their recording via the button below.
Now the text itself is a clickable link, improving accessibility
for email clients that may not render the button properly.

Updated MJML source template and all 4 locale files (en, fr, de, nl).
2026-05-11 23:04:27 +02:00
Florent Chehab c7ca5a621f 🐛(ci) install ffmpeg for summary tests
Add ffmpeg for summary tests
2026-05-11 23:00:55 +02:00
Florent Chehab 90ebe231ef 🐛(summary) complete webm support
When duration is not reported in the files metadata,
we directly infer the duration from the audio packets.
This prevents errors on webm files.

Very simple audio & video test files have been added
that cover relevant usecases to prevent regressions.
2026-05-11 23:00:54 +02:00
soyouzpanda 04f2a9ebdc ⬆️(mail) fix dependencies not having resolved or integrity field
Update dependencies to the latest minor versions fixed that
by re-resolving the fields.
This is needed for packaging as many distribution retrieve
node modules into the npm cache and then tries to install
node modules into the project without any internet connection.
Since there is no resolved/integrity field, it fails to
get packages from the cache.
2026-05-11 12:45:02 +02:00
renovate[bot] 6a8eb79b41 ⬆️(dependencies) update django to v5.2.14 [SECURITY] 2026-05-11 12:03:18 +02:00
leo bc35046b3a 🩹(summary) fix bug in assign_user
Fix bug in speaker assignment which occurs when LIVEKIT_VERIFY_SSL
is True.
2026-05-07 18:17:15 +02:00
leo 1612d8b2d4 (audio) assign users to diarization speaker results using VAD
Introduce a new user assignment mechanism to for more friendly output
than the current (SPEAKER_0, SPEAKER_1, ...). Use the VAD metadata to
compare speech intervals with those returned by WhisperX. User with the
highest overlap score above a defined threshold is assigned to each segment.
This method allows for multi-speaker scenarios for a single account.
2026-05-07 12:45:00 +02:00
lebaudantoine f8937fc0a1 ♻️(frontend) improve and simplify accessibility font override logic
Fix compatibility issues with the DINUM frontend image, which
overrides the default `font-sans` value.

Simplify the implementation by having the JavaScript layer only
toggle well-scoped CSS classes responsible for accessibility font
overrides. This makes the behavior more predictable and restoring
default styles straightforward.

Also clarify the intent of the hook by making its accessibility
purpose explicit and moving its usage to the App component, where
it better fits the application lifecycle.
2026-05-07 11:20:15 +02:00
Cyril 97b5e3e65c (frontend) add font selector in accessibility settings
Dropdown with description and FR/EN/NL translations.
2026-05-07 11:20:15 +02:00
Cyril b917d82f7e (frontend) apply font preference to app layout
Hook, CSS variable and LiveKit integration for custom fonts.
2026-05-07 11:20:15 +02:00
Cyril 82d146cdf5 (frontend) install accessibility font packages
Lexend, Atkinson Hyperlegible Next and OpenDyslexic via fontsource.
2026-05-07 11:20:15 +02:00
Cyril cbfeea0a4e (frontend) add uiFont preference to accessibility store
Add UiFont type with four options and Extend AccessibilityState.
2026-05-07 11:20:15 +02:00
leo a695758da4 ♻️(summary) refactor tasks signature and make transcription tz-aware
The tasks endpoint used non-timezone-aware date and time values and split
them into separate variables, which is unconventional. Refactor the
implementation to use timezone-aware datetime objects and align transcription
formatting with the user-declared timezone. Update the source of truth for
recording start time to FileInfo.started_at for improved precision. Adjust
the task signature in preparation for upcoming user assignment work, which
will require `started_at`, `ended_at`, and `metadata_filename`.
2026-05-06 18:33:03 +02:00
Damien Laine 4c5b6de8f3 (backend) make LiveKit Egress recording encoding configurable
Expose RECORDING_ENCODING_* settings to override the default LiveKit
Egress preset (H264_720P_30). When RECORDING_ENCODING_ENABLED is True,
the provided width/height/framerate/bitrate/keyframe values are passed
as advanced EncodingOptions. Lowering framerate and bitrate reduces
recording file size and egress worker CPU load.

Disabled by default, preserving current behaviour.
2026-05-05 18:26:49 +02:00
Florent Chehab cf4e347589 (helm) add support multiple transcribe worker / endpoint
Udate the helm chart to support multiple transcribe worker in
the summary service.
This is useful when using multiple WhisperX instances to have one deployment
for each endpoint. This enables some kind of horizontal scaling (we still
keep one call per WhisperX endpoint but can have multiple WhisperX endpoints)
2026-05-05 09:16:50 +02:00
tuanaiseo fc260b2686 🔒️(frontend) room ids are generated with non-cryptographic rand
Room identifiers are created with `Math.random()`, which is predictable
and not suitable for security-sensitive identifiers. Predictable
room IDs increase the risk of room enumeration and unauthorized
access attempts, especially when IDs are part of join URLs.

Affected files: generateRoomId.ts

Signed-off-by: tuanaiseo <221258316+tuanaiseo@users.noreply.github.com>
2026-05-04 23:47:47 +02:00
lebaudantoine cd7799997e 🧑‍💻(bin) update release tooling to support uv-based deps management
Following the switch from pip to uv, prepare the release workflow
to automatically run `uv lock` on backend and
keep dependencies up to date.
2026-05-04 22:36:07 +02:00
EpsilonFO a2bccf4f4f 🐛(backend) make start-recording atomic and fault-tolerant
Wrap Recording and RecordingAccess creation in a single transaction so a
partial failure does not leave orphan rows, and return 409 instead of 500
when a recording is already in progress for the room.

When the worker fails to start, transition the Recording to
FAILED_TO_START so the unique partial constraint on (room, status) no
longer blocks future recording attempts on the same room.
2026-05-04 22:15:14 +02:00
Sanjay Santhanam 6830250f2c ♻️(frontend) standardize role terminology across localizations
Fixes #1126 - Inconsistent role terminology in localization files.

Standardize on 'host' as the primary role term across en, de, and nl
locales, replacing mixed usage of 'administrator', 'organizer', 'admin',
'Organisator:in', 'Organisierende', and 'organisator'.
2026-05-04 18:44:20 +02:00
leo 0c0ce87947 🔒️(backend) validate Room configuration with Pydantic schema
Room.configuration accepted arbitrary JSON without validation, allowing unsafe
or malformed payloads to be stored and creating a security risk. Define a
Pydantic schema to enforce structure and constraints, and add validation
at the serializer level to reject invalid inputs.
2026-05-04 18:10:44 +02:00
renovate[bot] 597eba6e8a ⬆️(dependencies) update postcss to v8.5.10 [SECURITY] 2026-05-04 16:48:46 +02:00
renovate[bot] 47dbc271ba ⬆️(dependencies) update webpack-dev-server to v5.2.1 [SECURITY] 2026-05-04 15:34:30 +02:00
renovate[bot] c3adcc8ff3 ⬆️(dependencies) update pytest to v9.0.3 [SECURITY] 2026-05-04 14:24:53 +02:00
124 changed files with 5108 additions and 584 deletions
+5
View File
@@ -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:
+22
View File
@@ -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
View File
@@ -109,7 +109,7 @@ k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'liv
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', 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'])
+7
View File
@@ -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"
+58
View File
@@ -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.
+12
View File
@@ -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
+23 -27
View File
@@ -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": {
+1 -1
View File
@@ -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": [
+2 -2
View File
@@ -177,7 +177,7 @@ class MetadataCollector:
def save(self):
"""Serialize collected events and upload as JSON to S3."""
logger.info("Persisting metadata")
logger.info("Persisting metadata...")
participants = []
for k, v in self.participants.items():
@@ -372,7 +372,7 @@ async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
async def cleanup():
logger.info("Shutting down metadata collector")
logger.info("Shutting down metadata collector...")
await metadata_collector.aclose()
ctx.add_shutdown_callback(cleanup)
+27
View File
@@ -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"]
+27 -10
View File
@@ -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,
)
+27 -3
View File
@@ -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")
+1 -1
View File
@@ -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
+43
View File
@@ -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(
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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" },
+3
View File
@@ -0,0 +1,3 @@
declare module '@fontsource-variable/lexend' {}
declare module '@fontsource-variable/atkinson-hyperlegible-next' {}
declare module '@fontsource/opendyslexic' {}
+18
View File
@@ -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;
}
+150 -4
View File
@@ -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": [
{
+4 -1
View File
@@ -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",
+121
View File
@@ -0,0 +1,121 @@
<svg width="102" height="72" viewBox="0 0 102 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_13227_1168)">
<g filter="url(#filter0_d_13227_1168)">
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#969EB0"/>
<rect x="16" y="8.77759" width="51.852" height="41.4816" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#969EB0" stroke-width="1.1926"/>
<rect x="16.5963" y="9.37389" width="50.6595" height="40.289" rx="7.12186" stroke="#181B24" stroke-opacity="0.6" stroke-width="1.1926"/>
<g filter="url(#filter1_d_13227_1168)">
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter2_d_13227_1168)">
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="21.1851" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter3_d_13227_1168)">
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="13.9629" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
<g filter="url(#filter4_d_13227_1168)">
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#969EB0"/>
<rect x="43.2222" y="30.8149" width="19.4445" height="14.2593" rx="2.5926" fill="#181B24" fill-opacity="0.6"/>
</g>
</g>
<g filter="url(#filter5_d_13227_1168)">
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#7E98FF"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="#181B24" fill-opacity="0.7"/>
<rect x="52.2964" y="34.7036" width="33.7038" height="28.5186" rx="7.71815" fill="url(#paint0_linear_13227_1168)" fill-opacity="0.05"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#7E98FF" stroke-width="1.1926"/>
<rect x="52.8927" y="35.2999" width="32.5112" height="27.326" rx="7.12186" stroke="#181B24" stroke-opacity="0.45" stroke-width="1.1926"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 65.2593 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#7E98FF"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 71.7407 54.1482)" fill="#181B24" fill-opacity="0.45"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#FF706E"/>
<rect width="5.1852" height="5.1852" rx="1.94445" transform="matrix(-1 0 0 1 78.2222 54.1482)" fill="#181B24" fill-opacity="0.45"/>
</g>
<g filter="url(#filter6_d_13227_1168)">
<path d="M50.3653 53.4428C51.0628 53.4428 51.6941 53.2682 52.2593 52.9191C52.8304 52.576 53.2844 52.1135 53.6211 51.5316C53.9638 50.9558 54.1352 50.3156 54.1352 49.6112C54.1352 48.9006 53.9638 48.2543 53.6211 47.6724C53.2844 47.0965 52.8304 46.634 52.2593 46.2849C51.6941 45.9418 51.0628 45.7703 50.3653 45.7703H47.344C46.1836 45.7703 45.1555 45.6539 44.2596 45.4211C43.3637 45.1884 42.5731 44.7871 41.8876 44.2174C41.2022 43.6539 40.598 42.8698 40.0749 41.8652C39.9185 41.5711 39.7412 41.3843 39.5428 41.3046C39.3504 41.225 39.158 41.1852 38.9656 41.1852C38.7251 41.1852 38.5086 41.2924 38.3162 41.5068C38.1298 41.7151 38.0366 42.0183 38.0366 42.4165C38.0366 44.1133 38.217 45.6386 38.5777 46.9924C38.9445 48.3523 39.5037 49.5131 40.2552 50.4749C41.0128 51.4366 41.9778 52.1717 43.1503 52.6801C44.3287 53.1886 45.7266 53.4428 47.344 53.4428H50.3653ZM47.6056 42.2603V56.9161C47.6056 57.2224 47.7048 57.4858 47.9032 57.7063C48.1076 57.9268 48.3692 58.0371 48.6878 58.0371C48.9043 58.0371 49.0997 57.985 49.274 57.8809C49.4544 57.7829 49.6649 57.6175 49.9054 57.3847L57.0212 50.6035C57.1955 50.4381 57.3158 50.2697 57.3819 50.0981C57.4481 49.9266 57.4811 49.7643 57.4811 49.6112C57.4811 49.4641 57.4481 49.3049 57.3819 49.1333C57.3158 48.9618 57.1955 48.7934 57.0212 48.628L49.9054 41.7825C49.6889 41.5742 49.4815 41.4241 49.2831 41.3322C49.0907 41.2342 48.8862 41.1852 48.6698 41.1852C48.3631 41.1852 48.1076 41.2863 47.9032 41.4884C47.7048 41.6906 47.6056 41.9478 47.6056 42.2603Z" fill="#969EB0"/>
</g>
</g>
<defs>
<filter id="filter0_d_13227_1168" x="7.82784" y="4.69151" width="68.1964" height="57.826" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter1_d_13227_1168" x="17.3569" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter2_d_13227_1168" x="17.3569" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter3_d_13227_1168" x="39.394" y="10.1348" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter4_d_13227_1168" x="39.394" y="26.9868" width="27.1006" height="21.9156" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.91407"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter5_d_13227_1168" x="44.1242" y="30.6175" width="50.0479" height="44.8629" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<filter id="filter6_d_13227_1168" x="29.8645" y="37.0992" width="35.7887" height="33.1961" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4.08608"/>
<feGaussianBlur stdDeviation="4.08608"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0941176 0 0 0 0 0.105882 0 0 0 0 0.141176 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_13227_1168"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_13227_1168" result="shape"/>
</filter>
<linearGradient id="paint0_linear_13227_1168" x1="69.1483" y1="34.7036" x2="69.1483" y2="63.2222" gradientUnits="userSpaceOnUse">
<stop stop-color="#F6F8F9" stop-opacity="0.975"/>
<stop offset="1" stop-color="#F6F8F9" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_13227_1168">
<rect width="102" height="72" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.7 KiB

+2
View File
@@ -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')}
@@ -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 />
@@ -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 />
@@ -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])
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Aufzeichnung nicht verfügbar",
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur 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."
}
}
}
+26 -4
View File
@@ -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.",
+10
View File
@@ -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": {
+2 -2
View File
@@ -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."
}
}
}
+26 -4
View File
@@ -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.",
+10
View File
@@ -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": {
+22
View File
@@ -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"
+10
View File
@@ -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": {
+2 -2
View File
@@ -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."
}
}
}
+24 -2
View File
@@ -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.",
+10
View File
@@ -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": {
+7 -2
View File
@@ -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>
+5 -1
View File
@@ -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">
+30 -10
View File
@@ -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
}
+19
View File
@@ -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 -4
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
import { proxy } from 'valtio'
type State = {
isOpen: boolean
}
export const roomPiPStore = proxy<State>({
isOpen: false,
})
+13
View File
@@ -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;
+19 -5
View File
@@ -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