mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 21:58:29 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11de0bd408 | |||
| 5632e44a87 | |||
| 71f9032681 | |||
| 88dc205ac3 | |||
| 4b7e212db8 | |||
| b852f40778 | |||
| 56f9605bd3 | |||
| 65bd76698f | |||
| 653e67de56 | |||
| 9cb19e1906 |
+8
-4
@@ -8,6 +8,14 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) add per-recording encoding quality presets to start-recording API
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
git
|
||||||
|
- 💥(backend) replace recording encoding options with a profile model
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||||
@@ -222,10 +230,6 @@ and this project adheres to
|
|||||||
- ♿️(frontend) focus side panel container on open #1452
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
- 🐛(summary) whisper call error handling
|
- 🐛(summary) whisper call error handling
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- ✨(frontend) add screen share zoom controls #1498
|
|
||||||
|
|
||||||
## [1.23.0] - 2026-07-08
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+122
@@ -16,6 +16,128 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Recording encoding settings replaced by a resolution/profile model
|
||||||
|
|
||||||
|
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
|
||||||
|
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
|
||||||
|
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
|
||||||
|
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
|
||||||
|
fps and video bitrate.
|
||||||
|
|
||||||
|
**The following environment variables are no longer read. If they are still set in
|
||||||
|
your deployment they are silently ignored, and your recordings will be encoded with
|
||||||
|
the new defaults instead of your tuned values.**
|
||||||
|
|
||||||
|
| Removed variable | Replaced by |
|
||||||
|
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
|
||||||
|
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
|
||||||
|
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
|
||||||
|
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
|
||||||
|
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
|
||||||
|
|
||||||
|
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||||
|
keep their names and meaning. The keyframe interval now defaults to `0` (unset,
|
||||||
|
encoder's choice) instead of `4.0`.
|
||||||
|
|
||||||
|
#### If you never set `RECORDING_ENCODING_ENABLED=True`
|
||||||
|
|
||||||
|
The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
|
||||||
|
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
|
||||||
|
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
|
||||||
|
Video output is therefore unchanged.
|
||||||
|
|
||||||
|
Audio and keyframing may not be. These values are now sent explicitly as advanced
|
||||||
|
`EncodingOptions` rather than relying on LiveKit's preset, so
|
||||||
|
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
|
||||||
|
now apply to every recording. They previously applied only when
|
||||||
|
`RECORDING_ENCODING_ENABLED` was `True`. **If you set either of them while the
|
||||||
|
feature was disabled, they had no effect and now do**; check them before upgrading.
|
||||||
|
|
||||||
|
If you never set them, no action is required: 128 kbps AAC is what the preset used,
|
||||||
|
and the keyframe interval now defaults to `0`, which leaves the field unset so the
|
||||||
|
encoder keeps picking it as before. Set `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0`
|
||||||
|
if you want fixed 4-second keyframes (the value the setting defaulted to while it
|
||||||
|
was gated behind `RECORDING_ENCODING_ENABLED`).
|
||||||
|
|
||||||
|
To keep letting LiveKit pick the encoding instead, set either default to an empty
|
||||||
|
value:
|
||||||
|
|
||||||
|
```
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=
|
||||||
|
```
|
||||||
|
|
||||||
|
#### If you had tuned `RECORDING_ENCODING_*` values
|
||||||
|
|
||||||
|
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
|
||||||
|
environment as a single-line Python/JSON dict literal (parsed with
|
||||||
|
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
|
||||||
|
add outer quotes in `.env`-style files):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
Both maps are validated at startup and a malformed one raises a `ValueError`:
|
||||||
|
|
||||||
|
- every entry of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` must declare `width` and
|
||||||
|
`height`, and every entry of `RECORDING_ENCODING_AVAILABLE_PROFILES` an `fps` and a
|
||||||
|
`kbps` map;
|
||||||
|
- every profile must define a `kbps` entry for **exactly** the keys of
|
||||||
|
`RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`; overriding one of the two maps usually
|
||||||
|
means overriding both;
|
||||||
|
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
|
||||||
|
when non-empty, must be keys of their respective map.
|
||||||
|
|
||||||
|
#### Breaking: custom worker services must accept `encoding_options`
|
||||||
|
|
||||||
|
Only concerns deployments pointing `RECORDING_WORKER_CLASSES` at their own worker
|
||||||
|
class. The shipped `VideoCompositeEgressService` and `AudioCompositeEgressService`
|
||||||
|
are already updated.
|
||||||
|
|
||||||
|
The `WorkerService` protocol's `start()` takes a third argument, and the mediator
|
||||||
|
now always passes it as a keyword when the recording carries no per-recording encoding:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# before
|
||||||
|
def start(self, room_id: str, recording_id: str) -> str: ...
|
||||||
|
|
||||||
|
# now
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
encoding_options: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> str: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Optional: per-recording encoding
|
||||||
|
|
||||||
|
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
|
||||||
|
start-recording API accepts an `encoding` object
|
||||||
|
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional. It
|
||||||
|
falls back to `RECORDING_ENCODING_DEFAULT_PROFILE`) that overrides the default for
|
||||||
|
a single recording. It does not enable or disable the
|
||||||
|
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
|
||||||
|
settings either way. Leaving it at `False` preserves the previous behaviour, where
|
||||||
|
every recording uses the server-side encoding: requests carrying
|
||||||
|
`options.encoding` are rejected with a `400` before the recording is created, so
|
||||||
|
nothing is persisted and no egress is started.
|
||||||
|
|
||||||
|
Before enabling it:
|
||||||
|
|
||||||
|
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
|
||||||
|
- as of this implementation, the frontend never sends `encoding`
|
||||||
|
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
|
||||||
|
egress has no video encoding to configure.
|
||||||
|
|
||||||
|
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
|
||||||
|
for the full setting reference, the shipped profile table and the tuning caveats.
|
||||||
|
|
||||||
## v1.30.0
|
## v1.30.0
|
||||||
|
|
||||||
### Removing S3 storage-event webhooks for recordings
|
### Removing S3 storage-event webhooks for recordings
|
||||||
|
|||||||
+41
-34
@@ -93,13 +93,13 @@ sequenceDiagram
|
|||||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
| **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_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_CUSTOM_ENCODING_ENABLED** | Boolean | `False` | Whether the start-recording API accepts a per-recording `encoding` object (resolution/profile) that overrides the default. When `False`, the API rejects per-recording `encoding`; when `True`, clients may pick from the available resolutions/profiles. The default encoding below is applied regardless of this flag. 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_AVAILABLE_RESOLUTIONS** | Dict | `{"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}` | Maps a resolution name to its `{"width", "height"}` in pixels. Both the default encoding and the per-recording start-recording API pick from these keys. |
|
||||||
| **RECORDING_ENCODING_HEIGHT** | Integer | `720` | Recording video height in pixels. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AVAILABLE_PROFILES** | Dict | `{"full": {"fps": 30, "kbps": {…}}, …}` | Maps a profile name to `{"fps", "kbps": {resolution: video_bitrate_kbps}}`. Every profile must define a bitrate for each available resolution (validated at startup). |
|
||||||
| **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_DEFAULT_RESOLUTION** | String | `"720p"` | Resolution used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. Leave unset (together with, or instead of, the default profile) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_VIDEO_BITRATE_KBPS** | Integer | `3000` | H.264 MAIN video bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_DEFAULT_PROFILE** | String | `"full"` | Profile used by the default encoding. When set, must be a key of `RECORDING_ENCODING_AVAILABLE_PROFILES`. Leave unset (together with, or instead of, the default resolution) to disable the custom default encoding and fall back to LiveKit's built-in preset (a startup warning is emitted). |
|
||||||
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
| **RECORDING_ENCODING_AUDIO_BITRATE_KBPS** | Integer | `128` | AAC audio bitrate in kbps used in the default encoding. |
|
||||||
| **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`. |
|
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `0.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. `0` leaves the field unset, letting the encoder pick; `4.0` is a standard VOD value. |
|
||||||
|
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
@@ -130,52 +130,59 @@ This allows you to verify which recordings are in progress, troubleshoot egress
|
|||||||
|
|
||||||
## Tuning recording encoding
|
## 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.
|
Every video recording is encoded from a default resolved from `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` and passed to LiveKit as advanced `EncodingOptions`. The shipped defaults (`full` profile) match LiveKit's built-in `H264_720P_30` preset. For a one-hour meeting that produces a file of roughly **1.4 GB**, which is often heavier than necessary for talking-head content and screen sharing; lowering the default profile/resolution shrinks it. If either default is left unset, no custom default encoding is built: a warning is logged at startup and LiveKit's built-in preset is used instead.
|
||||||
|
|
||||||
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.
|
Encoding is chosen from two maps: `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS` (`resolution → {"width", "height"}`) and `RECORDING_ENCODING_AVAILABLE_PROFILES` (`profile → {"fps", "kbps": {resolution: video_bitrate_kbps}}`):
|
||||||
|
|
||||||
|
- **Default**: `RECORDING_ENCODING_DEFAULT_PROFILE` + `RECORDING_ENCODING_DEFAULT_RESOLUTION` set the encoding used by every recording that doesn't override it. Leave either unset to fall back to LiveKit's built-in preset (a startup warning is emitted).
|
||||||
|
- **Per recording (opt-in)**: set `RECORDING_CUSTOM_ENCODING_ENABLED=True` to let clients override the default per recording. The start-recording API then accepts an `encoding` object selecting a `resolution` (required) and `profile` (optional): a resolution-only request keeps `RECORDING_ENCODING_DEFAULT_PROFILE` for fps and bitrate, so clients can only pick from pre-defined values. When `RECORDING_CUSTOM_ENCODING_ENABLED=False`, the API rejects any per-recording `encoding` and the default is used.
|
||||||
|
|
||||||
|
The resolved 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 the profile/resolution resolve to is what the encoder receives.
|
||||||
|
|
||||||
### How values map to GStreamer
|
### How values map to GStreamer
|
||||||
|
|
||||||
| Setting | GStreamer element | Property |
|
| Resolved value | GStreamer element | Property |
|
||||||
| ------------------------------------- | ----------------- | ---------------------------------- |
|
| ----------------------------------------- | ----------------- | ---------------------------------- |
|
||||||
| `RECORDING_ENCODING_WIDTH/HEIGHT` | capsfilter | `video/x-raw,width=W,height=H` |
|
| resolution `width` / `height` | capsfilter | `video/x-raw,width=W,height=H` |
|
||||||
| `RECORDING_ENCODING_FRAMERATE` | capsfilter | `framerate=F/1` |
|
| profile `fps` | capsfilter | `framerate=F/1` |
|
||||||
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | `x264enc` | `bitrate=kbps` (kilobits) |
|
| profile `kbps[resolution]` | `x264enc` | `bitrate=kbps` (kilobits) |
|
||||||
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
| `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` | `x264enc` | `key-int-max = interval × fps` |
|
||||||
| `RECORDING_ENCODING_AUDIO_BITRATE_KBPS` | `faac` | `bitrate = kbps × 1000` (bits) |
|
| `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.
|
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
|
### Built-in 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.
|
The default `RECORDING_ENCODING_AVAILABLE_PROFILES` ship four profiles. Framerate is fixed per profile; video bitrate (kbps) scales with resolution so quality stays consistent across sizes. File size scales roughly with `framerate × bitrate`, and so does egress CPU cost.
|
||||||
|
|
||||||
| Profile | Resolution | FPS | Video (kbps) | Audio (kbps) | Keyframe (s) | ~ size / 30 min | Egress CPU (vs. default) | Suitable for |
|
| Profile | FPS | 540p (kbps) | 720p (kbps) | 1080p (kbps) | Suitable for |
|
||||||
| ---------------------- | ---------- | --- | ------------ | ------------ | ------------ | --------------- | ------------------------ | --------------------------------------------------- |
|
| --------------- | --- | ----------- | ----------- | ------------ | -------------------------------------------------- |
|
||||||
| Default (preset) | 1280×720 | 30 | 3000 | 128 | 4 | **~690 MB** | 100 % | Unchanged LiveKit behaviour |
|
| `talking_heads` | 15 | 400 | 700 | 1200 | Talking-head dominant meetings + occasional slides |
|
||||||
| Balanced | 1280×720 | 20 | 1000 | 96 | 4 | ~240 MB | ~67 % | Mixed content, moderate motion |
|
| `text` | 15 | 600 | 1000 | 1800 | Frequent dense screen sharing (decks, IDE, docs) |
|
||||||
| **Low CPU / small file** | 1280×720 | 15 | 600 | 64 | 4 | **~150 MB** | ~50 % | Talking-head dominant meetings + occasional slides ★ |
|
| `mixed` | 20 | 900 | 1500 | 2500 | Mixed content, moderate motion |
|
||||||
| Slide-heavy | 1280×720 | 15 | 900 | 64 | 4 | ~210 MB | ~55 % | Frequent dense screen sharing (decks, IDE, docs) |
|
| `full` | 30 | 2000 | 3000 | 4500 | Highest fidelity; closest to the LiveKit default preset |
|
||||||
| 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.
|
To pick a profile per recording (requires `RECORDING_CUSTOM_ENCODING_ENABLED=True`), the client sends it in the start-recording request:
|
||||||
|
|
||||||
Environment variables for the **Low CPU / small file** profile:
|
```json
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To change the default encoding applied to every recording:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RECORDING_ENCODING_ENABLED=True
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||||
RECORDING_ENCODING_WIDTH=1280
|
RECORDING_ENCODING_DEFAULT_PROFILE=talking_heads
|
||||||
RECORDING_ENCODING_HEIGHT=720
|
|
||||||
RECORDING_ENCODING_FRAMERATE=15
|
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caveats
|
### 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).
|
- **Screen-share readability — think bits/frame, not bitrate**: at 720p, text legibility starts to break down below ~40 kbits/frame (= `bitrate ÷ framerate`). The `talking_heads` profile (700 kbps × 15 fps) sits just above that threshold, comfortable for talking heads with occasional slide sharing. The same bitrate at 30 fps would only deliver ~23 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 **`text`** profile (1000 kbps × 15 fps ≈ 67 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from django_pydantic_field.rest_framework import SchemaField
|
||||||
from pydantic import BaseModel, Field, field_serializer
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
Field,
|
||||||
|
field_serializer,
|
||||||
|
field_validator,
|
||||||
|
)
|
||||||
from pydantic import ValidationError as PydanticValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
@@ -244,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
|
class EncodingConfig(BaseModel):
|
||||||
|
"""Configuration options for recording encoding.
|
||||||
|
|
||||||
|
The allowed `resolution` and `profile` values are derived at validation time
|
||||||
|
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
|
||||||
|
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
|
||||||
|
to those maps is enough to make it accepted here.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
resolution: Target video resolution.
|
||||||
|
profile: Encoding profile to fps and kbps. When `None`,
|
||||||
|
`settings.RECORDING_ENCODING_DEFAULT_PROFILE` applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
resolution: str
|
||||||
|
profile: str | None = None
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
@field_validator("resolution")
|
||||||
|
@classmethod
|
||||||
|
def _validate_resolution(cls, value):
|
||||||
|
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("profile")
|
||||||
|
@classmethod
|
||||||
|
def _validate_profile(cls, value):
|
||||||
|
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
if value not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
class RecordingOptions(BaseModel):
|
||||||
"""Configuration options for recording.
|
"""Configuration options for recording.
|
||||||
|
|
||||||
@@ -264,7 +312,7 @@ class RecordingOptions(BaseModel):
|
|||||||
transcribe: bool | None = None
|
transcribe: bool | None = None
|
||||||
collect_metadata: bool | None = None
|
collect_metadata: bool | None = None
|
||||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||||
|
encoding: EncodingConfig | None = None
|
||||||
model_config = {"extra": "forbid"}
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ from core.recording.worker.exceptions import (
|
|||||||
RecordingStopError,
|
RecordingStopError,
|
||||||
)
|
)
|
||||||
from core.recording.worker.factories import (
|
from core.recording.worker.factories import (
|
||||||
|
build_encoding_options,
|
||||||
get_worker_service,
|
get_worker_service,
|
||||||
)
|
)
|
||||||
from core.recording.worker.mediator import (
|
from core.recording.worker.mediator import (
|
||||||
@@ -400,12 +401,34 @@ class RoomViewSet(
|
|||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data.get("options")
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if (
|
||||||
|
options is not None
|
||||||
|
and options.encoding is not None
|
||||||
|
and not settings.RECORDING_CUSTOM_ENCODING_ENABLED
|
||||||
|
):
|
||||||
|
# Per-recording encoding selection is gated by
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED. When disabled, recordings use
|
||||||
|
# encoding defined by RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
# and RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Per-recording encoding selection is disabled."},
|
||||||
|
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
options_data = options.model_dump(exclude_none=True) if options else {}
|
||||||
|
if options is not None and options.encoding is not None:
|
||||||
|
# Persist the resolved encoding (concrete width/height/framerate/
|
||||||
|
# bitrate) alongside the requested resolution/profile for traceability.
|
||||||
|
options_data["encoding"]["resolved"] = build_encoding_options(
|
||||||
|
options.encoding.resolution, options.encoding.profile
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
options=options_data,
|
||||||
)
|
)
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
|
|||||||
@@ -22,6 +22,46 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
|
|||||||
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
_RECORDING_AUDIO_FREQUENCY_HZ = 48000
|
||||||
|
|
||||||
|
|
||||||
|
def build_encoding_options(resolution, profile=None):
|
||||||
|
"""Assemble the LiveKit ``EncodingOptions`` kwargs for a resolution/profile.
|
||||||
|
|
||||||
|
Single source of truth shared by the default encoding
|
||||||
|
(``WorkerServiceConfig.from_settings``) and the per-recording encoding
|
||||||
|
persisted by the start-recording API, so both paths always produce the
|
||||||
|
same shape.
|
||||||
|
|
||||||
|
The profile-independent fields (audio bitrate, keyframe interval and the
|
||||||
|
pinned codec / frequency constants) are always included.
|
||||||
|
|
||||||
|
An omitted profile falls back to RECORDING_ENCODING_DEFAULT_PROFILE.
|
||||||
|
Framerate and bitrate are left to LiveKit only when the operator
|
||||||
|
declared no default profile at all.
|
||||||
|
"""
|
||||||
|
profile = profile or settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
|
||||||
|
options: Dict[str, Any] = {
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolution:
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[
|
||||||
|
resolution
|
||||||
|
]
|
||||||
|
options["width"] = resolution_config["width"]
|
||||||
|
options["height"] = resolution_config["height"]
|
||||||
|
|
||||||
|
if resolution and profile:
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
options["framerate"] = profile_config["fps"]
|
||||||
|
options["video_bitrate"] = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class WorkerServiceConfig:
|
class WorkerServiceConfig:
|
||||||
"""Declare Worker Service common configurations"""
|
"""Declare Worker Service common configurations"""
|
||||||
@@ -38,22 +78,16 @@ class WorkerServiceConfig:
|
|||||||
|
|
||||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||||
|
|
||||||
|
# The default encoding is resolved from the default profile/resolution and
|
||||||
|
# applied to every recording that carries no per-recording encoding.
|
||||||
|
# When either default is missing, we leave this as None so LiveKit falls
|
||||||
|
# back to its built-in preset.
|
||||||
|
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
|
||||||
encoding_options: Optional[Dict[str, Any]] = None
|
encoding_options: Optional[Dict[str, Any]] = None
|
||||||
if settings.RECORDING_ENCODING_ENABLED:
|
if resolution and profile:
|
||||||
# Single source of truth for the EncodingOptions kwargs:
|
encoding_options = build_encoding_options(resolution, profile)
|
||||||
# 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(
|
return cls(
|
||||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||||
@@ -78,7 +112,12 @@ class WorkerService(Protocol):
|
|||||||
def __init__(self, config: WorkerServiceConfig):
|
def __init__(self, config: WorkerServiceConfig):
|
||||||
"""Initialize the service with the given configuration."""
|
"""Initialize the service with the given configuration."""
|
||||||
|
|
||||||
def start(self, room_id: str, recording_id: str) -> str:
|
def start(
|
||||||
|
self,
|
||||||
|
room_id: str,
|
||||||
|
recording_id: str,
|
||||||
|
encoding_options: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> str:
|
||||||
"""Start a recording for a specified room."""
|
"""Start a recording for a specified room."""
|
||||||
|
|
||||||
def stop(self, worker_id: str) -> str:
|
def stop(self, worker_id: str) -> str:
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
|
|||||||
raise RecordingStartError()
|
raise RecordingStartError()
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
|
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
|
||||||
try:
|
try:
|
||||||
worker_id = self._worker_service.start(room_name, recording.id)
|
worker_id = self._worker_service.start(
|
||||||
|
room_name, recording.id, encoding_options=encoding_options
|
||||||
|
)
|
||||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||||
|
|||||||
@@ -76,28 +76,28 @@ class BaseEgressService:
|
|||||||
|
|
||||||
return "FAILED_TO_STOP"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the egress process for a recording (not implemented in the base class).
|
"""Start the egress process for a recording (not implemented in the base class).
|
||||||
Each derived class must implement this method, providing the necessary parameters for
|
Each derived class must implement this method, providing the necessary parameters for
|
||||||
its specific egress type (e.g. audio_only, streaming output).
|
its specific egress type (e.g. audio_only, streaming output).
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("Subclass must implement this method.")
|
raise NotImplementedError("Subclass must implement this method.")
|
||||||
|
|
||||||
def _build_encoding_options(self):
|
def _resolve_encoding_options(self, encoding_options):
|
||||||
"""Build a LiveKit EncodingOptions from the service config, or None.
|
"""Build a LiveKit EncodingOptions from a resolved kwargs dict, or None.
|
||||||
|
|
||||||
|
``encoding_options`` is the per-recording dict persisted by the API in
|
||||||
|
``recording.options["encoding"]["resolved"]``; it falls back to the
|
||||||
|
default encoding carried by the service config.
|
||||||
|
|
||||||
When None is returned, the caller should omit the `advanced` field so
|
When None is returned, the caller should omit the `advanced` field so
|
||||||
LiveKit Egress falls back to its built-in preset (H264_720P_30).
|
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
|
encoding_options = encoding_options or self._config.encoding_options
|
||||||
if not opts:
|
if not encoding_options:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return livekit_api.EncodingOptions(**opts)
|
return livekit_api.EncodingOptions(**encoding_options)
|
||||||
|
|
||||||
|
|
||||||
class VideoCompositeEgressService(BaseEgressService):
|
class VideoCompositeEgressService(BaseEgressService):
|
||||||
@@ -105,7 +105,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
|
|
||||||
hrid = "video-recording-composite-livekit-egress"
|
hrid = "video-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the video composite egress process for a recording."""
|
"""Start the video composite egress process for a recording."""
|
||||||
|
|
||||||
# Save room's recording as a mp4 video file.
|
# Save room's recording as a mp4 video file.
|
||||||
@@ -126,7 +126,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
|||||||
"layout": "speaker-light",
|
"layout": "speaker-light",
|
||||||
}
|
}
|
||||||
|
|
||||||
advanced = self._build_encoding_options()
|
advanced = self._resolve_encoding_options(encoding_options)
|
||||||
if advanced is not None:
|
if advanced is not None:
|
||||||
request_kwargs["advanced"] = advanced
|
request_kwargs["advanced"] = advanced
|
||||||
|
|
||||||
@@ -145,8 +145,13 @@ class AudioCompositeEgressService(BaseEgressService):
|
|||||||
|
|
||||||
hrid = "audio-recording-composite-livekit-egress"
|
hrid = "audio-recording-composite-livekit-egress"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id, encoding_options=None):
|
||||||
"""Start the audio composite egress process for a recording."""
|
"""Start the audio composite egress process for a recording.
|
||||||
|
|
||||||
|
``encoding_options`` is accepted for signature compatibility with the
|
||||||
|
WorkerService protocol but ignored: audio-only egress has no
|
||||||
|
encoding to configure.
|
||||||
|
"""
|
||||||
|
|
||||||
# Save room's recording as an ogg audio file.
|
# Save room's recording as an ogg audio file.
|
||||||
file_type = livekit_api.EncodedFileType.OGG
|
file_type = livekit_api.EncodedFileType.OGG
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Tests for the per-recording encoding resolution in BaseEgressService."""
|
||||||
|
|
||||||
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import override_settings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
|
|
||||||
|
from core.api.serializers import EncodingConfig
|
||||||
|
from core.recording.worker.factories import build_encoding_options
|
||||||
|
from core.recording.worker.services import VideoCompositeEgressService
|
||||||
|
|
||||||
|
|
||||||
|
def make_config():
|
||||||
|
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
|
||||||
|
config = Mock()
|
||||||
|
config.bucket_args = {
|
||||||
|
"endpoint": "https://s3.test.com",
|
||||||
|
"access_key": "test_key",
|
||||||
|
"secret": "test_secret",
|
||||||
|
"region": "test-region",
|
||||||
|
"bucket": "test-bucket",
|
||||||
|
"force_path_style": True,
|
||||||
|
}
|
||||||
|
config.encoding_options = None
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def service():
|
||||||
|
"""Return a VideoCompositeEgressService with mocked handle_request."""
|
||||||
|
svc = VideoCompositeEgressService(make_config())
|
||||||
|
svc._handle_request = Mock()
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_options_without_profile_uses_default_profile():
|
||||||
|
"""A resolution-only config should fall back to the default profile.
|
||||||
|
|
||||||
|
Left unset, framerate and video_bitrate take LiveKit's own EncodingOptions
|
||||||
|
defaults. The profile-independent fields (audio bitrate, keyframe interval,
|
||||||
|
codec/frequency pins) are always present, matching the default encoding.
|
||||||
|
"""
|
||||||
|
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
|
||||||
|
settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved = build_encoding_options("540p")
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 960,
|
||||||
|
"height": 540,
|
||||||
|
"framerate": default_profile["fps"],
|
||||||
|
"video_bitrate": default_profile["kbps"]["540p"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
|
||||||
|
def test_build_options_omits_profile_fields_without_default_profile():
|
||||||
|
"""With no default profile declared, framerate/bitrate are left to LiveKit."""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
|
||||||
|
assert resolved == {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
}
|
||||||
|
assert "framerate" not in resolved
|
||||||
|
assert "video_bitrate" not in resolved
|
||||||
|
|
||||||
|
|
||||||
|
def test_encoding_config_requires_resolution():
|
||||||
|
"""A profile-only or empty encoding config should be rejected at validation."""
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig(profile="mixed")
|
||||||
|
with pytest.raises(PydanticValidationError):
|
||||||
|
EncodingConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# --- _resolve_encoding_options ---
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoding_options", [None, {}])
|
||||||
|
def test_resolve_options_returns_none_when_empty(service, encoding_options):
|
||||||
|
"""Resolver should return None when the resolved dict is empty or missing."""
|
||||||
|
assert service._resolve_encoding_options(encoding_options) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resolution",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"profile",
|
||||||
|
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
|
||||||
|
)
|
||||||
|
def test_resolve_profile_resolution_combinations(service, profile, resolution):
|
||||||
|
"""Every (profile, resolution) pair should resolve to the values from settings."""
|
||||||
|
resolution_config = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
|
||||||
|
expected_width = resolution_config["width"]
|
||||||
|
expected_height = resolution_config["height"]
|
||||||
|
profile_config = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
|
||||||
|
expected_fps = profile_config["fps"]
|
||||||
|
expected_bitrate = profile_config["kbps"][resolution]
|
||||||
|
|
||||||
|
resolved = build_encoding_options(resolution, profile)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == expected_width
|
||||||
|
assert result.height == expected_height
|
||||||
|
assert result.framerate == expected_fps
|
||||||
|
assert result.video_bitrate == expected_bitrate
|
||||||
|
# Profile-independent fields match the default encoding, never dropped.
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
|
assert result.audio_frequency == 48000
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_options_none_profile_uses_default_profile(service):
|
||||||
|
"""A missing profile should resolve to the default profile's fps/bitrate."""
|
||||||
|
default_profile = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[
|
||||||
|
settings.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
]
|
||||||
|
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == default_profile["fps"]
|
||||||
|
assert result.video_bitrate == default_profile["kbps"]["720p"]
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(RECORDING_ENCODING_DEFAULT_PROFILE="")
|
||||||
|
def test_resolve_options_passes_zero_when_no_default_profile(service):
|
||||||
|
"""With no default profile, fps/bitrate reach LiveKit unset (protobuf 0).
|
||||||
|
|
||||||
|
The pinned codec / audio fields are still applied.
|
||||||
|
"""
|
||||||
|
resolved = build_encoding_options("720p", None)
|
||||||
|
result = service._resolve_encoding_options(resolved)
|
||||||
|
|
||||||
|
assert result.width == 1280
|
||||||
|
assert result.height == 720
|
||||||
|
assert result.framerate == 0
|
||||||
|
assert result.video_bitrate == 0
|
||||||
|
assert result.audio_bitrate == settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS
|
||||||
|
assert result.video_codec == livekit_api.VideoCodec.H264_MAIN
|
||||||
|
assert result.audio_codec == livekit_api.AudioCodec.AAC
|
||||||
@@ -40,6 +40,16 @@ def test_settings():
|
|||||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||||
"AWS_S3_REGION_NAME": "test-region",
|
"AWS_S3_REGION_NAME": "test-region",
|
||||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS": {
|
||||||
|
"720p": {"width": 1280, "height": 720}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_PROFILES": {
|
||||||
|
"full": {"fps": 30, "kbps": {"720p": 3000}}
|
||||||
|
},
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION": "720p",
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE": "full",
|
||||||
|
"RECORDING_ENCODING_AUDIO_BITRATE_KBPS": 128,
|
||||||
|
"RECORDING_ENCODING_KEY_FRAME_INTERVAL_S": 4.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Use override_settings to properly patch Django settings
|
# Use override_settings to properly patch Django settings
|
||||||
@@ -66,8 +76,18 @@ def test_config_initialization(default_config):
|
|||||||
"bucket": "test-bucket",
|
"bucket": "test-bucket",
|
||||||
"force_path_style": True,
|
"force_path_style": True,
|
||||||
}
|
}
|
||||||
# Encoding override is opt-in; disabled by default.
|
# The default encoding is always resolved from the default profile/resolution.
|
||||||
assert default_config.encoding_options is None
|
assert default_config.encoding_options == {
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 30,
|
||||||
|
"video_bitrate": 3000,
|
||||||
|
"audio_bitrate": 128,
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"video_codec": livekit_api_codec.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api_codec.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_config_immutability(default_config):
|
def test_config_immutability(default_config):
|
||||||
@@ -76,6 +96,7 @@ def test_config_immutability(default_config):
|
|||||||
default_config.output_folder = "new/path"
|
default_config.output_folder = "new/path"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("custom_encoding_enabled", [True, False])
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
@@ -84,23 +105,25 @@ def test_config_immutability(default_config):
|
|||||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||||
AWS_S3_REGION_NAME="test-region",
|
AWS_S3_REGION_NAME="test-region",
|
||||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||||
RECORDING_ENCODING_ENABLED=True,
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"720p": {"width": 1280, "height": 720}},
|
||||||
RECORDING_ENCODING_WIDTH=1280,
|
RECORDING_ENCODING_AVAILABLE_PROFILES={"low": {"fps": 15, "kbps": {"720p": 600}}},
|
||||||
RECORDING_ENCODING_HEIGHT=720,
|
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
|
||||||
RECORDING_ENCODING_FRAMERATE=15,
|
RECORDING_ENCODING_DEFAULT_PROFILE="low",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600,
|
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
|
||||||
)
|
)
|
||||||
def test_config_encoding_options_enabled():
|
def test_config_encoding_options_default(custom_encoding_enabled):
|
||||||
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
|
"""The default encoding is always resolved from the default profile/resolution.
|
||||||
|
|
||||||
The dict mixes operator-tunable values from settings with pinned codec /
|
The default fallback resolves the default profile/resolution and mixes those
|
||||||
frequency constants, so the services layer can simply unpack it.
|
operator-tunable values with pinned codec / frequency constants. This works
|
||||||
|
regardless of RECORDING_CUSTOM_ENCODING_ENABLED, which only gates the
|
||||||
|
per-recording API, so both toggle states produce the same default.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
WorkerServiceConfig.from_settings.cache_clear()
|
with override_settings(RECORDING_CUSTOM_ENCODING_ENABLED=custom_encoding_enabled):
|
||||||
config = WorkerServiceConfig.from_settings()
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
assert config.encoding_options == {
|
assert config.encoding_options == {
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
@@ -115,6 +138,27 @@ def test_config_encoding_options_enabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("default_resolution", "default_profile"),
|
||||||
|
[("", "full"), ("720p", ""), ("", "")],
|
||||||
|
)
|
||||||
|
def test_config_encoding_options_none_when_default_missing(
|
||||||
|
test_settings, default_resolution, default_profile
|
||||||
|
):
|
||||||
|
"""A missing default resolution/profile leaves encoding_options None.
|
||||||
|
|
||||||
|
The service then omits the `advanced` field so LiveKit uses its built-in preset.
|
||||||
|
"""
|
||||||
|
with override_settings(
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION=default_resolution,
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE=default_profile,
|
||||||
|
):
|
||||||
|
WorkerServiceConfig.from_settings.cache_clear()
|
||||||
|
config = WorkerServiceConfig.from_settings()
|
||||||
|
|
||||||
|
assert config.encoding_options is None
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
# Verify worker service call
|
# Verify worker service call
|
||||||
expected_room_name = str(mock_recording.room.id)
|
expected_room_name = str(mock_recording.room.id)
|
||||||
mock_worker_service.start.assert_called_once_with(
|
mock_worker_service.start.assert_called_once_with(
|
||||||
expected_room_name, mock_recording.id
|
expected_room_name, mock_recording.id, encoding_options=None
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify recording updates
|
# Verify recording updates
|
||||||
@@ -64,6 +64,38 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
|
def test_start_recording_passes_resolved_encoding(
|
||||||
|
mock_update_metadata, mediator, mock_worker_service
|
||||||
|
):
|
||||||
|
"""The resolved encoding persisted in recording.options reaches the worker."""
|
||||||
|
mock_worker_service.start.return_value = "test-worker-123"
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
"key_frame_interval": 4.0,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
}
|
||||||
|
mock_recording = RecordingFactory(
|
||||||
|
status=RecordingStatusChoices.INITIATED,
|
||||||
|
worker_id=None,
|
||||||
|
options={
|
||||||
|
"encoding": {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": resolved,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mediator.start(mock_recording)
|
||||||
|
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
Test rooms API endpoints in the Meet core app: start recording.
|
Test rooms API endpoints in the Meet core app: start recording.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from livekit import api as livekit_api
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
@@ -470,6 +471,224 @@ def test_start_recording_options_unknown_field_rejected(settings):
|
|||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_valid(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Should accept a valid encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_rejected_when_custom_encoding_disabled(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Per-recording encoding is rejected when RECORDING_CUSTOM_ENCODING_ENABLED is off."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = False
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert not Recording.objects.filter(room=room).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_persists_resolved_encoding(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""The resolved encoding should be persisted in recording.options alongside
|
||||||
|
the requested resolution/profile for traceability."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options["encoding"] == {
|
||||||
|
"resolution": "720p",
|
||||||
|
"profile": "talking_heads",
|
||||||
|
"resolved": {
|
||||||
|
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
|
||||||
|
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
|
||||||
|
"video_codec": livekit_api.VideoCodec.H264_MAIN,
|
||||||
|
"audio_codec": livekit_api.AudioCodec.AAC,
|
||||||
|
"audio_frequency": 48000,
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"framerate": 15,
|
||||||
|
"video_bitrate": 700,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_resolution_only_uses_default_profile(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""An encoding without a profile should resolve the default profile."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
settings.RECORDING_ENCODING_DEFAULT_PROFILE = "talking_heads"
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "540p"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
resolved = recording.options["encoding"]["resolved"]
|
||||||
|
assert resolved["width"] == 960
|
||||||
|
assert resolved["height"] == 540
|
||||||
|
assert resolved["framerate"] == 15
|
||||||
|
assert resolved["video_bitrate"] == 400
|
||||||
|
# The requested payload is persisted as sent: no profile was asked for.
|
||||||
|
assert "profile" not in recording.options["encoding"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_forwards_resolved_encoding_to_worker(
|
||||||
|
settings, mock_worker_service, mock_worker_service_factory
|
||||||
|
):
|
||||||
|
"""The resolved encoding should passed on to the worker."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
mock_worker_service.start.return_value = "egress-123"
|
||||||
|
|
||||||
|
with mock.patch("core.services.room_management.RoomManagement.update_metadata"):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {
|
||||||
|
"encoding": {"resolution": "720p", "profile": "talking_heads"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
mock_worker_service.start.assert_called_once_with(
|
||||||
|
str(room.id),
|
||||||
|
recording.id,
|
||||||
|
encoding_options=recording.options["encoding"]["resolved"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_invalid_resolution(settings):
|
||||||
|
"""Should reject invalid encoding resolution values."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_encoding_unknown_key_rejected(settings):
|
||||||
|
"""Should reject unknown keys in encoding configuration."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
settings.RECORDING_CUSTOM_ENCODING_ENABLED = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{
|
||||||
|
"mode": "screen_recording",
|
||||||
|
"options": {"encoding": {"bitrate": 9000}},
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_recording_options_without_encoding_unchanged(
|
||||||
|
settings, mock_worker_service_factory, mock_worker_manager
|
||||||
|
):
|
||||||
|
"""Requests without encoding should keep existing options behavior."""
|
||||||
|
settings.RECORDING_ENABLE = True
|
||||||
|
room = RoomFactory()
|
||||||
|
user = UserFactory()
|
||||||
|
room.accesses.create(user=user, role="owner")
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||||
|
{"mode": "screen_recording", "options": {"language": "fr"}},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
recording = Recording.objects.get(room=room)
|
||||||
|
assert recording.options == {"language": "fr"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["foo", 12])
|
@pytest.mark.parametrize("value", ["foo", 12])
|
||||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||||
"""Should reject non-boolean transcribe values."""
|
"""Should reject non-boolean transcribe values."""
|
||||||
|
|||||||
+173
-20
@@ -23,6 +23,8 @@ import dj_database_url
|
|||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from configurations import Configuration, values
|
from configurations import Configuration, values
|
||||||
from lasuite.configuration.values import SecretFileValue
|
from lasuite.configuration.values import SecretFileValue
|
||||||
|
from pydantic import BaseModel, PositiveInt, TypeAdapter
|
||||||
|
from pydantic import ValidationError as PydanticValidationError
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
from sentry_sdk.integrations.logging import ignore_logger
|
from sentry_sdk.integrations.logging import ignore_logger
|
||||||
|
|
||||||
@@ -53,6 +55,28 @@ def get_release():
|
|||||||
return "NA" # Default: not available
|
return "NA" # Default: not available
|
||||||
|
|
||||||
|
|
||||||
|
class Resolution(BaseModel):
|
||||||
|
"""Shape of a RECORDING_ENCODING_AVAILABLE_RESOLUTIONS entry."""
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
width: PositiveInt
|
||||||
|
height: PositiveInt
|
||||||
|
|
||||||
|
|
||||||
|
class Profile(BaseModel):
|
||||||
|
"""Shape of a RECORDING_ENCODING_AVAILABLE_PROFILES entry."""
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
fps: PositiveInt
|
||||||
|
kbps: dict[str, PositiveInt]
|
||||||
|
|
||||||
|
|
||||||
|
RESOLUTION_MAP_ADAPTER = TypeAdapter(dict[str, Resolution])
|
||||||
|
PROFILE_MAP_ADAPTER = TypeAdapter(dict[str, Profile])
|
||||||
|
|
||||||
|
|
||||||
class Base(Configuration):
|
class Base(Configuration):
|
||||||
"""
|
"""
|
||||||
This is the base configuration every configuration (aka environment) should inherit from. It
|
This is the base configuration every configuration (aka environment) should inherit from. It
|
||||||
@@ -745,35 +769,81 @@ class Base(Configuration):
|
|||||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||||
# recordings), whose request never carries advanced EncodingOptions.
|
# 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).
|
# A default encoding is applied to every recording: it is resolved from the default
|
||||||
# When enabled, the values below are passed to LiveKit as EncodingOptions
|
# profile and resolution below and passed to LiveKit as EncodingOptions (advanced),
|
||||||
# (advanced) and replace the preset. Lowering framerate and bitrate reduces
|
# replacing LiveKit's built-in H264_720P_30 preset. Lowering framerate and bitrate
|
||||||
# output file size and CPU load on the egress worker.
|
# reduces output file size and CPU load on the egress worker. If either
|
||||||
RECORDING_ENCODING_ENABLED = values.BooleanValue(
|
# RECORDING_ENCODING_DEFAULT_RESOLUTION or RECORDING_ENCODING_DEFAULT_PROFILE is
|
||||||
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
|
# unset, no default encoding is built (a startup warning is emitted) and LiveKit's
|
||||||
|
# built-in preset is used instead.
|
||||||
|
#
|
||||||
|
# RECORDING_CUSTOM_ENCODING_ENABLED gates whether the start-recording API lets a
|
||||||
|
# client override that default per recording (via an `encoding` object selecting a
|
||||||
|
# resolution/profile). When False, the API rejects per-recording `encoding` and
|
||||||
|
# every recording uses the default; when True, clients may pick from the
|
||||||
|
# available resolutions/profiles below.
|
||||||
|
RECORDING_CUSTOM_ENCODING_ENABLED = values.BooleanValue(
|
||||||
|
False, environ_name="RECORDING_CUSTOM_ENCODING_ENABLED", environ_prefix=None
|
||||||
)
|
)
|
||||||
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
|
|
||||||
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None
|
# Map resolution string -> {"width", "height"} in pixels.
|
||||||
)
|
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
|
||||||
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue(
|
{
|
||||||
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None
|
"540p": {"width": 960, "height": 540},
|
||||||
)
|
"720p": {"width": 1280, "height": 720},
|
||||||
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue(
|
"1080p": {"width": 1920, "height": 1080},
|
||||||
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None
|
},
|
||||||
)
|
environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
|
||||||
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
|
|
||||||
3000,
|
|
||||||
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
|
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Map profile string -> {"fps", "kbps": {resolution: video_bitrate_kbps}}.
|
||||||
|
# Bitrate scales with resolution so quality stays consistent across sizes.
|
||||||
|
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
|
||||||
|
{
|
||||||
|
"talking_heads": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 400, "720p": 700, "1080p": 1200},
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"fps": 15,
|
||||||
|
"kbps": {"540p": 600, "720p": 1000, "1080p": 1800},
|
||||||
|
},
|
||||||
|
"mixed": {
|
||||||
|
"fps": 20,
|
||||||
|
"kbps": {"540p": 900, "720p": 1500, "1080p": 2500},
|
||||||
|
},
|
||||||
|
"full": {
|
||||||
|
"fps": 30,
|
||||||
|
"kbps": {"540p": 2000, "720p": 3000, "1080p": 4500},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Defaults used when no profile/resolution is specified per recording.
|
||||||
|
# Must be keys of the two dicts above (validated at startup).
|
||||||
|
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
|
||||||
|
"full",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
|
||||||
|
"720p",
|
||||||
|
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Settings independent of profile/resolution.
|
||||||
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
|
||||||
128,
|
128,
|
||||||
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
|
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S = values.FloatValue(
|
||||||
4.0,
|
0.0,
|
||||||
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
|
environ_name="RECORDING_ENCODING_KEY_FRAME_INTERVAL_S",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
@@ -781,6 +851,7 @@ class Base(Configuration):
|
|||||||
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||||
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|
||||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -1169,6 +1240,86 @@ class Base(Configuration):
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _check_recording_encoding_maps(cls):
|
||||||
|
"""Ensure the per-recording encoding maps are well-formed and consistent.
|
||||||
|
|
||||||
|
Each entry of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS must declare a width and
|
||||||
|
a height, each entry of RECORDING_ENCODING_AVAILABLE_PROFILES an fps and a kbps
|
||||||
|
map, and every profile must define a bitrate for each declared resolution.
|
||||||
|
|
||||||
|
The default profile / resolution feed the default encoding. When either is
|
||||||
|
missing, no custom default encoding can be built: a warning is emitted and
|
||||||
|
recordings fall back to LiveKit's built-in preset. When both are set, they
|
||||||
|
must reference keys that actually exist in the maps above.
|
||||||
|
"""
|
||||||
|
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
|
||||||
|
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
|
||||||
|
|
||||||
|
for name, adapter in (
|
||||||
|
("RECORDING_ENCODING_AVAILABLE_RESOLUTIONS", RESOLUTION_MAP_ADAPTER),
|
||||||
|
("RECORDING_ENCODING_AVAILABLE_PROFILES", PROFILE_MAP_ADAPTER),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
adapter.validate_python(getattr(cls, name))
|
||||||
|
except PydanticValidationError as exc:
|
||||||
|
raise ValueError(f"{name} is malformed: {exc}") from exc
|
||||||
|
|
||||||
|
for (
|
||||||
|
profile,
|
||||||
|
profile_config,
|
||||||
|
) in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items(): # pylint: disable=no-member
|
||||||
|
profile_resolutions = set(profile_config["kbps"])
|
||||||
|
if profile_resolutions != resolutions:
|
||||||
|
raise ValueError(
|
||||||
|
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
|
||||||
|
"define a bitrate for exactly the resolutions in "
|
||||||
|
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
|
||||||
|
f"{resolutions ^ profile_resolutions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check that default resolutions and profiles are actually defined
|
||||||
|
if (
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION
|
||||||
|
and cls.RECORDING_ENCODING_DEFAULT_RESOLUTION not in resolutions
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_RESOLUTION}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS ({sorted(resolutions)})."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_PROFILE
|
||||||
|
and cls.RECORDING_ENCODING_DEFAULT_PROFILE not in profiles
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE "
|
||||||
|
f"'{cls.RECORDING_ENCODING_DEFAULT_PROFILE}' is not a key of "
|
||||||
|
f"RECORDING_ENCODING_AVAILABLE_PROFILES ({sorted(profiles)})."
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_RESOLUTION",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"RECORDING_ENCODING_DEFAULT_PROFILE",
|
||||||
|
cls.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not value
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
warnings.warn(
|
||||||
|
f"{' and '.join(missing)} not set; recordings will use LiveKit's "
|
||||||
|
"built-in encoding preset instead of a custom default encoding.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def post_setup(cls):
|
def post_setup(cls):
|
||||||
"""Post setup configuration.
|
"""Post setup configuration.
|
||||||
@@ -1182,6 +1333,8 @@ class Base(Configuration):
|
|||||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cls._check_recording_encoding_maps()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cls.SUMMARY_SERVICE_VERSION == 1
|
cls.SUMMARY_SERVICE_VERSION == 1
|
||||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { Track } from 'livekit-client'
|
|||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
@@ -49,8 +48,6 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
|||||||
disableTileControls?: boolean
|
disableTileControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
||||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
@@ -92,8 +89,6 @@ export const ParticipantTile: (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const isRemoteScreenShare =
|
|
||||||
isScreenShare && !trackReference.participant.isLocal
|
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
const participantColor = getParticipantColor(trackReference.participant)
|
const participantColor = getParticipantColor(trackReference.participant)
|
||||||
@@ -103,38 +98,11 @@ export const ParticipantTile: (
|
|||||||
})
|
})
|
||||||
const participantName = name || identity || 'Unknown'
|
const participantName = name || identity || 'Unknown'
|
||||||
|
|
||||||
// Hover + idle tracking for the focus overlay (pin, effects, mute buttons).
|
|
||||||
const [isTileHovered, setIsTileHovered] = React.useState(false)
|
|
||||||
const [isIdle, setIsIdle] = React.useState(false)
|
|
||||||
const idleTimerRef = React.useRef<number | null>(null)
|
|
||||||
|
|
||||||
const handleTileMouseMove = React.useCallback(() => {
|
|
||||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
|
||||||
idleTimerRef.current = window.setTimeout(
|
|
||||||
() => setIsIdle(true),
|
|
||||||
MOUSE_IDLE_TIME
|
|
||||||
)
|
|
||||||
setIsIdle(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const isOverlayVisible = hasKeyboardFocus || (isTileHovered && !isIdle)
|
|
||||||
|
|
||||||
// tileRef: fullscreen target. setRefs merges it with the forwarded ref on the same node.
|
|
||||||
const tileRef = React.useRef<HTMLDivElement>(null)
|
|
||||||
const setRefs = React.useCallback(
|
|
||||||
(node: HTMLDivElement | null) => {
|
|
||||||
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
||||||
if (typeof ref === 'function') ref(node)
|
|
||||||
else if (ref)
|
|
||||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
||||||
},
|
|
||||||
[ref]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
|
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -152,57 +120,8 @@ export const ParticipantTile: (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const isVideoTrack =
|
|
||||||
isTrackReference(trackReference) &&
|
|
||||||
(trackReference.publication?.kind === 'video' ||
|
|
||||||
trackReference.source === Track.Source.Camera ||
|
|
||||||
trackReference.source === Track.Source.ScreenShare)
|
|
||||||
|
|
||||||
let trackMedia: React.ReactNode = null
|
|
||||||
if (isVideoTrack) {
|
|
||||||
if (isRemoteScreenShare) {
|
|
||||||
trackMedia = (
|
|
||||||
<ScreenShareZoomableVideo
|
|
||||||
trackRef={trackReference}
|
|
||||||
tileRef={tileRef}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
manageSubscription={autoManageSubscription}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
trackMedia = (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
manageSubscription={autoManageSubscription}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (isTrackReference(trackReference)) {
|
|
||||||
trackMedia = (
|
|
||||||
<AudioTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||||
ref={setRefs}
|
|
||||||
style={{ position: 'relative' }}
|
|
||||||
{...interactiveProps}
|
|
||||||
onMouseEnter={() => setIsTileHovered(true)}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
setIsTileHovered(false)
|
|
||||||
setIsIdle(false)
|
|
||||||
if (idleTimerRef.current) {
|
|
||||||
window.clearTimeout(idleTimerRef.current)
|
|
||||||
idleTimerRef.current = null
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseMove={handleTileMouseMove}
|
|
||||||
>
|
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
{trackReference.participant.isLocal && (
|
{trackReference.participant.isLocal && (
|
||||||
@@ -210,7 +129,23 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{trackMedia}
|
{isTrackReference(trackReference) &&
|
||||||
|
(trackReference.publication?.kind === 'video' ||
|
||||||
|
trackReference.source === Track.Source.Camera ||
|
||||||
|
trackReference.source === Track.Source.ScreenShare) ? (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
isTrackReference(trackReference) && (
|
||||||
|
<AudioTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
color={participantColor}
|
color={participantColor}
|
||||||
@@ -229,7 +164,7 @@ export const ParticipantTile: (
|
|||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
isVisible={isOverlayVisible}
|
hasKeyboardFocus={hasKeyboardFocus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
|
|||||||
+40
-12
@@ -1,22 +1,44 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { FocusButton } from './FocusButton'
|
import { FocusButton } from './FocusButton'
|
||||||
import { EffectsButton } from './EffectsButton'
|
import { EffectsButton } from './EffectsButton'
|
||||||
import { MuteButton } from './MuteButton'
|
import { MuteButton } from './MuteButton'
|
||||||
|
import { ZoomButton } from './ZoomButton'
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
type FadeOverlayProps = {
|
type FadeOverlayProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
isVisible: boolean
|
hasKeyboardFocus: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||||
// Hover and idle tracking therefore lives on the tile, which still receives
|
const [active, setActive] = useState(false)
|
||||||
// the pointer events, and comes back in as isVisible.
|
const idleTimerRef = useRef<number | null>(null)
|
||||||
const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
|
||||||
|
const clearIdleTimer = () => {
|
||||||
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const armIdleTimer = () => {
|
||||||
|
clearIdleTimer()
|
||||||
|
idleTimerRef.current = window.setTimeout(() => {
|
||||||
|
setActive(false)
|
||||||
|
}, MOUSE_IDLE_TIME)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleActivity = () => {
|
||||||
|
setActive(true)
|
||||||
|
armIdleTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => clearIdleTimer, [])
|
||||||
|
|
||||||
|
const isVisible = hasKeyboardFocus || active
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -28,10 +50,15 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
pointerEvents: 'none',
|
|
||||||
})}
|
})}
|
||||||
data-visible={isVisible || undefined}
|
data-visible={isVisible || undefined}
|
||||||
aria-hidden={!isVisible}
|
aria-hidden={!isVisible}
|
||||||
|
onMouseEnter={handleActivity}
|
||||||
|
onMouseMove={handleActivity}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
clearIdleTimer()
|
||||||
|
setActive(false)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isVisible && children}
|
{isVisible && children}
|
||||||
</div>
|
</div>
|
||||||
@@ -40,10 +67,10 @@ const FadeOverlay = ({ children, isVisible }: FadeOverlayProps) => {
|
|||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
export const ParticipantTileFocus = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
isVisible,
|
hasKeyboardFocus,
|
||||||
}: {
|
}: {
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
isVisible: boolean
|
hasKeyboardFocus: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const participant = trackRef.participant
|
const participant = trackRef.participant
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
@@ -51,7 +78,7 @@ export const ParticipantTileFocus = ({
|
|||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FadeOverlay isVisible={isVisible}>
|
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
@@ -60,7 +87,6 @@ export const ParticipantTileFocus = ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||||
pointerEvents: 'auto',
|
|
||||||
_hover: {
|
_hover: {
|
||||||
opacity: 0.95,
|
opacity: 0.95,
|
||||||
},
|
},
|
||||||
@@ -68,7 +94,7 @@ export const ParticipantTileFocus = ({
|
|||||||
>
|
>
|
||||||
<HStack gap={0.5} padding={0.5}>
|
<HStack gap={0.5} padding={0.5}>
|
||||||
<FocusButton trackRef={trackRef} />
|
<FocusButton trackRef={trackRef} />
|
||||||
{!isScreenShare && (
|
{!isScreenShare ? (
|
||||||
<>
|
<>
|
||||||
{isLocal ? (
|
{isLocal ? (
|
||||||
<EffectsButton />
|
<EffectsButton />
|
||||||
@@ -76,6 +102,8 @@ export const ParticipantTileFocus = ({
|
|||||||
canMute && <MuteButton participant={participant} />
|
canMute && <MuteButton participant={participant} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiFullscreenLine } from '@remixicon/react'
|
||||||
|
|
||||||
|
export const ZoomButton = ({
|
||||||
|
trackRef,
|
||||||
|
}: {
|
||||||
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||||
|
trackRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isFullscreenAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={t('fullScreen')}
|
||||||
|
onPress={() => toggleFullScreen()}
|
||||||
|
>
|
||||||
|
<RiFullscreenLine />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { VideoTrack } from '@livekit/components-react'
|
|
||||||
import { type TrackReference } from '@livekit/components-core'
|
|
||||||
import { memo } from 'react'
|
|
||||||
|
|
||||||
interface ScreenShareVideoTrackProps {
|
|
||||||
trackRef: TrackReference
|
|
||||||
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
|
||||||
manageSubscription?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zoom/pan updates the wrapper transform only; skip VideoTrack re-renders.
|
|
||||||
export const ScreenShareVideoTrack = memo(
|
|
||||||
({
|
|
||||||
trackRef,
|
|
||||||
onSubscriptionStatusChanged,
|
|
||||||
manageSubscription,
|
|
||||||
}: ScreenShareVideoTrackProps) => (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={trackRef}
|
|
||||||
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
|
||||||
manageSubscription={manageSubscription}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack'
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Button } from '@/primitives'
|
|
||||||
import {
|
|
||||||
RiCollapseDiagonalLine,
|
|
||||||
RiExpandDiagonalLine,
|
|
||||||
RiFullscreenExitLine,
|
|
||||||
RiZoomInLine,
|
|
||||||
RiZoomOutLine,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Toolbar } from 'react-aria-components'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
import { isMacintosh } from '@/utils/livekit'
|
|
||||||
import { srOnly } from '@/styles/a11y'
|
|
||||||
|
|
||||||
interface ScreenShareZoomControlsProps {
|
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
isZoomed: boolean
|
|
||||||
zoomPercentage: number
|
|
||||||
canZoomIn: boolean
|
|
||||||
canZoomOut: boolean
|
|
||||||
onZoomIn: () => void
|
|
||||||
onZoomOut: () => void
|
|
||||||
onResetZoom: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ScreenShareZoomControls = ({
|
|
||||||
containerRef,
|
|
||||||
isZoomed,
|
|
||||||
zoomPercentage,
|
|
||||||
canZoomIn,
|
|
||||||
canZoomOut,
|
|
||||||
onZoomIn,
|
|
||||||
onZoomOut,
|
|
||||||
onResetZoom,
|
|
||||||
}: ScreenShareZoomControlsProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
|
|
||||||
const hadFocusInCollapsibleRef = useRef(false)
|
|
||||||
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
||||||
// Tracks whether this tile's container triggered fullscreen (vs another share's).
|
|
||||||
const wasThisTileFullscreen = useRef(false)
|
|
||||||
const isFullscreenAvailable = document.fullscreenEnabled
|
|
||||||
|
|
||||||
// Covers Esc and browser UI exits, not just the toolbar button.
|
|
||||||
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
|
||||||
useEffect(() => {
|
|
||||||
const onChange = () => {
|
|
||||||
const isThisTileFullscreen =
|
|
||||||
document.fullscreenElement === containerRef.current
|
|
||||||
setIsFullscreen(isThisTileFullscreen)
|
|
||||||
|
|
||||||
if (isThisTileFullscreen) {
|
|
||||||
wasThisTileFullscreen.current = true
|
|
||||||
announce(t('fullScreenEntered'), 'assertive')
|
|
||||||
} else if (wasThisTileFullscreen.current) {
|
|
||||||
wasThisTileFullscreen.current = false
|
|
||||||
announce(t('fullScreenExited'), 'assertive')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('fullscreenchange', onChange)
|
|
||||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
|
||||||
}, [announce, t, containerRef])
|
|
||||||
|
|
||||||
// Back at 100 % the collapsible controls are disabled and hidden, which drops
|
|
||||||
// keyboard focus on the body. Hand it to the zoom in button instead, the only
|
|
||||||
// control of that group still reachable.
|
|
||||||
useEffect(() => {
|
|
||||||
if (isZoomed || !hadFocusInCollapsibleRef.current) return
|
|
||||||
hadFocusInCollapsibleRef.current = false
|
|
||||||
zoomInButtonRef.current?.focus()
|
|
||||||
}, [isZoomed])
|
|
||||||
|
|
||||||
const toggleFullScreen = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
if (document.fullscreenElement === containerRef.current) {
|
|
||||||
await document.exitFullscreen()
|
|
||||||
} else {
|
|
||||||
// Tile container so zoom controls stay visible in fullscreen.
|
|
||||||
await containerRef.current?.requestFullscreen()
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error toggling fullscreen:', error)
|
|
||||||
}
|
|
||||||
}, [containerRef])
|
|
||||||
|
|
||||||
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '12px',
|
|
||||||
right: '12px',
|
|
||||||
zIndex: 2,
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{/* react-aria Toolbar: left/right arrows move between the controls and
|
|
||||||
Tab leaves the group as a whole, as the toolbar role implies. */}
|
|
||||||
<Toolbar
|
|
||||||
aria-label={t('toolbarLabel')}
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'primaryDark.50',
|
|
||||||
borderRadius: '2rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
opacity: 0.7,
|
|
||||||
transition: 'opacity 200ms linear',
|
|
||||||
_hover: {
|
|
||||||
opacity: 0.95,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span className={srOnly}>
|
|
||||||
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
|
||||||
</span>
|
|
||||||
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
|
|
||||||
trick keeps overflow:hidden from clipping focus rings. */}
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
|
|
||||||
padding: '3px',
|
|
||||||
margin: '-3px',
|
|
||||||
})}
|
|
||||||
style={{
|
|
||||||
maxWidth: isZoomed ? '12rem' : '0',
|
|
||||||
opacity: isZoomed ? 1 : 0,
|
|
||||||
}}
|
|
||||||
aria-hidden={!isZoomed}
|
|
||||||
onFocus={() => {
|
|
||||||
hadFocusInCollapsibleRef.current = true
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
// Disabling a focused button blurs it with no relatedTarget, so the
|
|
||||||
// flag must survive that case for the effect above to rescue focus.
|
|
||||||
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fitToWindow')}
|
|
||||||
aria-label={t('fitToWindow')}
|
|
||||||
isDisabled={!isZoomed}
|
|
||||||
onPress={onResetZoom}
|
|
||||||
>
|
|
||||||
<RiFullscreenExitLine size={20} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('zoomOutWithShortcut', {
|
|
||||||
shortcut: wheelShortcutVisual,
|
|
||||||
})}
|
|
||||||
aria-label={t('zoomOut')}
|
|
||||||
isDisabled={!isZoomed || !canZoomOut}
|
|
||||||
onPress={onZoomOut}
|
|
||||||
>
|
|
||||||
<RiZoomOutLine size={20} />
|
|
||||||
</Button>
|
|
||||||
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={css({
|
|
||||||
color: 'white',
|
|
||||||
fontSize: '0.8125rem',
|
|
||||||
fontWeight: 500,
|
|
||||||
minWidth: '3.25rem',
|
|
||||||
textAlign: 'center',
|
|
||||||
userSelect: 'none',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '0 0.25rem',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{zoomPercentage} %
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
ref={zoomInButtonRef}
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
|
|
||||||
aria-label={t('zoomIn')}
|
|
||||||
isDisabled={!canZoomIn}
|
|
||||||
onPress={onZoomIn}
|
|
||||||
>
|
|
||||||
<RiZoomInLine size={20} />
|
|
||||||
</Button>
|
|
||||||
{isFullscreenAvailable && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
|
||||||
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
|
||||||
onPress={toggleFullScreen}
|
|
||||||
>
|
|
||||||
{isFullscreen ? (
|
|
||||||
<RiCollapseDiagonalLine size={20} />
|
|
||||||
) : (
|
|
||||||
<RiExpandDiagonalLine size={20} />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Toolbar>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { type TrackReference } from '@livekit/components-core'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
|
|
||||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
||||||
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
|
|
||||||
import { ScreenShareVideoTrack } from './ScreenShareVideoTrack'
|
|
||||||
|
|
||||||
interface ScreenShareZoomableVideoProps {
|
|
||||||
trackRef: TrackReference
|
|
||||||
tileRef: React.RefObject<HTMLDivElement | null>
|
|
||||||
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
|
||||||
manageSubscription?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ScreenShareZoomableVideo = ({
|
|
||||||
trackRef,
|
|
||||||
tileRef,
|
|
||||||
onSubscriptionStatusChanged,
|
|
||||||
manageSubscription,
|
|
||||||
}: ScreenShareZoomableVideoProps) => {
|
|
||||||
const zoom = useScreenShareZoom()
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
|
||||||
const announce = useScreenReaderAnnounce()
|
|
||||||
|
|
||||||
// SR announcement: announce zoom level on change, with a one-time pan hint
|
|
||||||
// on the first zoom above 100 % per session.
|
|
||||||
const prevZoomRef = useRef(zoom.zoomPercentage)
|
|
||||||
const hasAnnouncedPanHint = useRef(false)
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevZoomRef.current === zoom.zoomPercentage) return
|
|
||||||
const wasAtDefault = prevZoomRef.current <= 100
|
|
||||||
prevZoomRef.current = zoom.zoomPercentage
|
|
||||||
|
|
||||||
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
|
|
||||||
hasAnnouncedPanHint.current = true
|
|
||||||
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
|
|
||||||
} else {
|
|
||||||
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
|
|
||||||
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
|
|
||||||
|
|
||||||
// Attach keyboard listener on the tile container (has tabIndex=0).
|
|
||||||
useEffect(() => {
|
|
||||||
const el = tileRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.addEventListener('keydown', zoom.handleKeyDown)
|
|
||||||
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
|
||||||
}, [tileRef, zoom.handleKeyDown])
|
|
||||||
|
|
||||||
// Native wheel listener with { passive: false } so preventDefault works.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = zoom.surfaceElRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
|
||||||
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
|
||||||
}, [zoom.handleWheel, zoom.surfaceElRef])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
ref={zoom.surfaceElRef}
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
userSelect: 'none',
|
|
||||||
// Leaves the browser's native pinch-zoom available on touch devices
|
|
||||||
// while still routing single-pointer drags to useMove for panning.
|
|
||||||
touchAction: 'pinch-zoom',
|
|
||||||
})}
|
|
||||||
{...zoom.moveProps}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={zoom.transformElRef}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
transformOrigin: 'center center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ScreenShareVideoTrack
|
|
||||||
trackRef={trackRef}
|
|
||||||
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
|
||||||
manageSubscription={manageSubscription}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ScreenShareZoomControls
|
|
||||||
containerRef={tileRef}
|
|
||||||
isZoomed={zoom.isZoomed}
|
|
||||||
zoomPercentage={zoom.zoomPercentage}
|
|
||||||
canZoomIn={zoom.canZoomIn}
|
|
||||||
canZoomOut={zoom.canZoomOut}
|
|
||||||
onZoomIn={zoom.zoomIn}
|
|
||||||
onZoomOut={zoom.zoomOut}
|
|
||||||
onResetZoom={zoom.resetZoom}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
|
||||||
import { useMove } from 'react-aria'
|
|
||||||
import type { MoveMoveEvent } from '@react-types/shared'
|
|
||||||
import {
|
|
||||||
FULL_PICTURE_RATIO,
|
|
||||||
MIN_ZOOM,
|
|
||||||
PAN_STEP,
|
|
||||||
WHEEL_ZOOM_SPEED,
|
|
||||||
ZOOM_STEP,
|
|
||||||
type PanOffset,
|
|
||||||
type ZoomSnapshot,
|
|
||||||
buildZoomSnapshot,
|
|
||||||
clampPan,
|
|
||||||
clampZoom,
|
|
||||||
getCursorFromZoomState,
|
|
||||||
getCursorPercentsFromWheelEvent,
|
|
||||||
getPanDeltaPercentsFromMove,
|
|
||||||
getPictureRatio,
|
|
||||||
getWheelPanOffset,
|
|
||||||
getZoomTransform,
|
|
||||||
} from '../utils/screenShareZoom'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages zoom and pan state for a remote screen share.
|
|
||||||
*
|
|
||||||
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
|
|
||||||
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
|
|
||||||
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
|
|
||||||
* when the toolbar UI needs to update (zoom level change, drag end).
|
|
||||||
*
|
|
||||||
* Drag/touch panning is handled by react-aria's useMove (moveProps).
|
|
||||||
* Ctrl/Cmd + wheel zoom is a native listener (must be non-passive to
|
|
||||||
* preventDefault and block browser page zoom).
|
|
||||||
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
|
|
||||||
* tile container (which has tabIndex=0 and focus).
|
|
||||||
*/
|
|
||||||
export const useScreenShareZoom = () => {
|
|
||||||
const zoomRef = useRef(MIN_ZOOM)
|
|
||||||
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
|
|
||||||
const draggingRef = useRef(false)
|
|
||||||
|
|
||||||
// The consumer binds these to the inner transform div and the outer drag surface.
|
|
||||||
const transformElRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const surfaceElRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
// Snapshot store: subscribers are notified only on explicit flush() calls.
|
|
||||||
const snapshotRef = useRef<ZoomSnapshot>(
|
|
||||||
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
|
|
||||||
)
|
|
||||||
const listenersRef = useRef(new Set<() => void>())
|
|
||||||
|
|
||||||
const subscribe = useCallback((cb: () => void) => {
|
|
||||||
listenersRef.current.add(cb)
|
|
||||||
return () => {
|
|
||||||
listenersRef.current.delete(cb)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getSnapshot = useCallback(() => snapshotRef.current, [])
|
|
||||||
|
|
||||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
||||||
|
|
||||||
const flush = useCallback(() => {
|
|
||||||
snapshotRef.current = buildZoomSnapshot(
|
|
||||||
zoomRef.current,
|
|
||||||
panRef.current,
|
|
||||||
draggingRef.current
|
|
||||||
)
|
|
||||||
listenersRef.current.forEach((cb) => cb())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const applyTransform = useCallback(() => {
|
|
||||||
const el = transformElRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// The video is letterboxed inside the surface by object-fit: contain, so the
|
|
||||||
// pan bounds depend on how much of the surface the picture actually covers.
|
|
||||||
// Read live rather than cached: both the tile and the shared resolution can
|
|
||||||
// change at any time.
|
|
||||||
const readPictureRatio = useCallback(() => {
|
|
||||||
const surface = surfaceElRef.current
|
|
||||||
const video = transformElRef.current?.querySelector('video')
|
|
||||||
if (!surface || !video) return FULL_PICTURE_RATIO
|
|
||||||
return getPictureRatio(
|
|
||||||
surface.clientWidth,
|
|
||||||
surface.clientHeight,
|
|
||||||
video.videoWidth,
|
|
||||||
video.videoHeight
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const applyCursor = useCallback(() => {
|
|
||||||
const el = surfaceElRef.current
|
|
||||||
if (!el) return
|
|
||||||
el.style.cursor = getCursorFromZoomState(
|
|
||||||
zoomRef.current,
|
|
||||||
draggingRef.current
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const zoomIn = useCallback(() => {
|
|
||||||
const next = clampZoom(zoomRef.current + ZOOM_STEP)
|
|
||||||
zoomRef.current = next
|
|
||||||
panRef.current = clampPan(panRef.current, next, readPictureRatio())
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
}, [applyTransform, applyCursor, flush, readPictureRatio])
|
|
||||||
|
|
||||||
const zoomOut = useCallback(() => {
|
|
||||||
const next = clampZoom(zoomRef.current - ZOOM_STEP)
|
|
||||||
zoomRef.current = next
|
|
||||||
panRef.current =
|
|
||||||
next <= MIN_ZOOM
|
|
||||||
? { x: 0, y: 0 }
|
|
||||||
: clampPan(panRef.current, next, readPictureRatio())
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
}, [applyTransform, applyCursor, flush, readPictureRatio])
|
|
||||||
|
|
||||||
const resetZoom = useCallback(() => {
|
|
||||||
zoomRef.current = MIN_ZOOM
|
|
||||||
panRef.current = { x: 0, y: 0 }
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
}, [applyTransform, applyCursor, flush])
|
|
||||||
|
|
||||||
// Must be attached with { passive: false } so preventDefault() blocks
|
|
||||||
// the browser's native Ctrl+scroll page zoom.
|
|
||||||
const handleWheel = useCallback(
|
|
||||||
(e: WheelEvent) => {
|
|
||||||
if (!e.ctrlKey && !e.metaKey) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const target = e.currentTarget as HTMLElement
|
|
||||||
const prev = zoomRef.current
|
|
||||||
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
|
||||||
const next = clampZoom(prev + delta)
|
|
||||||
|
|
||||||
if (next <= MIN_ZOOM) {
|
|
||||||
zoomRef.current = MIN_ZOOM
|
|
||||||
panRef.current = { x: 0, y: 0 }
|
|
||||||
} else {
|
|
||||||
const { cursorXPercent, cursorYPercent } =
|
|
||||||
getCursorPercentsFromWheelEvent(e, target)
|
|
||||||
zoomRef.current = next
|
|
||||||
panRef.current = getWheelPanOffset({
|
|
||||||
pan: panRef.current,
|
|
||||||
prevZoom: prev,
|
|
||||||
nextZoom: next,
|
|
||||||
cursorXPercent,
|
|
||||||
cursorYPercent,
|
|
||||||
ratio: readPictureRatio(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
[applyTransform, applyCursor, flush, readPictureRatio]
|
|
||||||
)
|
|
||||||
|
|
||||||
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
|
|
||||||
// here because moveProps is on the zoom surface, while focus is on the tile
|
|
||||||
// container, see handleKeyDown below.
|
|
||||||
const { moveProps } = useMove({
|
|
||||||
onMoveStart() {
|
|
||||||
if (zoomRef.current <= MIN_ZOOM) return
|
|
||||||
draggingRef.current = true
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
onMove(e: MoveMoveEvent) {
|
|
||||||
if (zoomRef.current <= MIN_ZOOM) return
|
|
||||||
|
|
||||||
const el = surfaceElRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
|
||||||
e.deltaX,
|
|
||||||
e.deltaY,
|
|
||||||
el
|
|
||||||
)
|
|
||||||
|
|
||||||
panRef.current = clampPan(
|
|
||||||
{
|
|
||||||
x: panRef.current.x + deltaXPercent,
|
|
||||||
y: panRef.current.y + deltaYPercent,
|
|
||||||
},
|
|
||||||
zoomRef.current,
|
|
||||||
readPictureRatio()
|
|
||||||
)
|
|
||||||
|
|
||||||
applyTransform()
|
|
||||||
// Mouse drag: skip flush (imperative-only) to avoid re-renders per frame.
|
|
||||||
// Keyboard: flush so the toolbar reflects the updated position.
|
|
||||||
if (e.pointerType === 'keyboard') {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onMoveEnd() {
|
|
||||||
draggingRef.current = false
|
|
||||||
applyTransform()
|
|
||||||
applyCursor()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const panBy = useCallback(
|
|
||||||
(dx: number, dy: number) => {
|
|
||||||
panRef.current = clampPan(
|
|
||||||
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
|
|
||||||
zoomRef.current,
|
|
||||||
readPictureRatio()
|
|
||||||
)
|
|
||||||
applyTransform()
|
|
||||||
flush()
|
|
||||||
},
|
|
||||||
[applyTransform, flush, readPictureRatio]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Attached to the tile container (not the zoom surface) where keyboard
|
|
||||||
// focus lives. Arrows pan, +/-/0 zoom.
|
|
||||||
const handleKeyDown = useCallback(
|
|
||||||
(e: KeyboardEvent) => {
|
|
||||||
const isZoomed = zoomRef.current > MIN_ZOOM
|
|
||||||
if (!isZoomed && e.key !== '+' && e.key !== '=') return
|
|
||||||
|
|
||||||
if (e.key.startsWith('Arrow') && e.target !== e.currentTarget) return
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(PAN_STEP, 0)
|
|
||||||
break
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(-PAN_STEP, 0)
|
|
||||||
break
|
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(0, PAN_STEP)
|
|
||||||
break
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault()
|
|
||||||
panBy(0, -PAN_STEP)
|
|
||||||
break
|
|
||||||
case '+':
|
|
||||||
case '=':
|
|
||||||
e.preventDefault()
|
|
||||||
zoomIn()
|
|
||||||
break
|
|
||||||
case '-':
|
|
||||||
e.preventDefault()
|
|
||||||
zoomOut()
|
|
||||||
break
|
|
||||||
case '0':
|
|
||||||
e.preventDefault()
|
|
||||||
resetZoom()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[panBy, zoomIn, zoomOut, resetZoom]
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
...snapshot,
|
|
||||||
transformElRef,
|
|
||||||
surfaceElRef,
|
|
||||||
moveProps,
|
|
||||||
zoomIn,
|
|
||||||
zoomOut,
|
|
||||||
resetZoom,
|
|
||||||
handleWheel,
|
|
||||||
handleKeyDown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
export const MIN_ZOOM = 1
|
|
||||||
export const MAX_ZOOM = 4
|
|
||||||
export const ZOOM_STEP = 0.1
|
|
||||||
export const WHEEL_ZOOM_SPEED = 0.002
|
|
||||||
export const PAN_STEP = 5
|
|
||||||
|
|
||||||
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
|
|
||||||
// cursor offset is measured from and the half extent the pan is clamped
|
|
||||||
// against, so the two stay consistent by construction.
|
|
||||||
export const HALF_EXTENT_PERCENT = 50
|
|
||||||
|
|
||||||
export interface PanOffset {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fraction of the surface each axis of the picture covers, in [0, 1].
|
|
||||||
export interface PictureRatio {
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
|
|
||||||
|
|
||||||
export interface ZoomSnapshot {
|
|
||||||
zoomLevel: number
|
|
||||||
zoomPercentage: number
|
|
||||||
panOffset: PanOffset
|
|
||||||
isZoomed: boolean
|
|
||||||
isDragging: boolean
|
|
||||||
canZoomIn: boolean
|
|
||||||
canZoomOut: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clampZoom = (value: number) => {
|
|
||||||
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restrict pan so the picture always covers the view. Pan is a % of the
|
|
||||||
// surface, in which object-fit: contain letterboxes the picture: its half
|
|
||||||
// extent is `ratio * 50` against a view half extent of 50, and scaling by
|
|
||||||
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
|
|
||||||
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
|
|
||||||
export const clampPan = (
|
|
||||||
pan: PanOffset,
|
|
||||||
zoom: number,
|
|
||||||
ratio: PictureRatio
|
|
||||||
): PanOffset => {
|
|
||||||
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * HALF_EXTENT_PERCENT)
|
|
||||||
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
|
|
||||||
return {
|
|
||||||
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
|
|
||||||
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-axis fraction of the surface covered by an object-fit: contain picture.
|
|
||||||
export const getPictureRatio = (
|
|
||||||
surfaceWidth: number,
|
|
||||||
surfaceHeight: number,
|
|
||||||
videoWidth: number,
|
|
||||||
videoHeight: number
|
|
||||||
): PictureRatio => {
|
|
||||||
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
|
|
||||||
return FULL_PICTURE_RATIO
|
|
||||||
}
|
|
||||||
const surfaceRatio = surfaceWidth / surfaceHeight
|
|
||||||
const videoRatio = videoWidth / videoHeight
|
|
||||||
return surfaceRatio > videoRatio
|
|
||||||
? { x: videoRatio / surfaceRatio, y: 1 }
|
|
||||||
: { x: 1, y: surfaceRatio / videoRatio }
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buildZoomSnapshot = (
|
|
||||||
zoom: number,
|
|
||||||
pan: PanOffset,
|
|
||||||
dragging: boolean
|
|
||||||
): ZoomSnapshot => {
|
|
||||||
return {
|
|
||||||
zoomLevel: zoom,
|
|
||||||
zoomPercentage: Math.round(zoom * 100),
|
|
||||||
panOffset: pan,
|
|
||||||
isZoomed: zoom > MIN_ZOOM,
|
|
||||||
isDragging: dragging,
|
|
||||||
canZoomIn: zoom < MAX_ZOOM,
|
|
||||||
canZoomOut: zoom > MIN_ZOOM,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
|
|
||||||
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
|
|
||||||
if (zoom <= MIN_ZOOM) return 'default'
|
|
||||||
return dragging ? 'grabbing' : 'grab'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the content point under the cursor anchored while zooming. With
|
|
||||||
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
|
|
||||||
// `z * (offset + pan)`, so holding it still gives:
|
|
||||||
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
|
|
||||||
export const getWheelPanOffset = ({
|
|
||||||
pan,
|
|
||||||
prevZoom,
|
|
||||||
nextZoom,
|
|
||||||
cursorXPercent,
|
|
||||||
cursorYPercent,
|
|
||||||
ratio,
|
|
||||||
}: {
|
|
||||||
pan: PanOffset
|
|
||||||
prevZoom: number
|
|
||||||
nextZoom: number
|
|
||||||
cursorXPercent: number
|
|
||||||
cursorYPercent: number
|
|
||||||
ratio: PictureRatio
|
|
||||||
}): PanOffset => {
|
|
||||||
const panShift = 1 / nextZoom - 1 / prevZoom
|
|
||||||
return clampPan(
|
|
||||||
{
|
|
||||||
x: pan.x + cursorXPercent * panShift,
|
|
||||||
y: pan.y + cursorYPercent * panShift,
|
|
||||||
},
|
|
||||||
nextZoom,
|
|
||||||
ratio
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert cursor pixel position to a % offset from the surface center.
|
|
||||||
export const getCursorPercentsFromWheelEvent = (
|
|
||||||
e: WheelEvent,
|
|
||||||
target: HTMLElement
|
|
||||||
) => {
|
|
||||||
const rect = target.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
cursorXPercent:
|
|
||||||
((e.clientX - rect.left) / rect.width) * 100 - HALF_EXTENT_PERCENT,
|
|
||||||
cursorYPercent:
|
|
||||||
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert useMove pixel deltas to % of the surface dimensions.
|
|
||||||
export const getPanDeltaPercentsFromMove = (
|
|
||||||
deltaX: number,
|
|
||||||
deltaY: number,
|
|
||||||
surface: HTMLElement
|
|
||||||
) => {
|
|
||||||
const rect = surface.getBoundingClientRect()
|
|
||||||
return {
|
|
||||||
deltaXPercent: (deltaX / rect.width) * 100,
|
|
||||||
deltaYPercent: (deltaY / rect.height) * 100,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,10 +16,6 @@ export type ShortcutId =
|
|||||||
| 'recording'
|
| 'recording'
|
||||||
| 'reaction'
|
| 'reaction'
|
||||||
| 'fullscreen'
|
| 'fullscreen'
|
||||||
| 'zoom-in'
|
|
||||||
| 'zoom-out'
|
|
||||||
| 'zoom-reset'
|
|
||||||
| 'zoom-pan'
|
|
||||||
|
|
||||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||||
shortcutCatalog.find((item) => item.id === id)
|
shortcutCatalog.find((item) => item.id === id)
|
||||||
@@ -28,7 +24,7 @@ export type ShortcutDescriptor = {
|
|||||||
id: ShortcutId
|
id: ShortcutId
|
||||||
category: ShortcutCategory
|
category: ShortcutCategory
|
||||||
shortcut?: Shortcut
|
shortcut?: Shortcut
|
||||||
kind?: 'press' | 'longPress' | 'arrows'
|
kind?: 'press' | 'longPress'
|
||||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
@@ -90,27 +86,4 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
|||||||
category: 'interaction',
|
category: 'interaction',
|
||||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||||
},
|
},
|
||||||
// Screen share zoom keys are unmodified, so they are bound on the focused
|
|
||||||
// tile instead of being registered globally. They are listed here so the
|
|
||||||
// shortcuts panel stays exhaustive.
|
|
||||||
{
|
|
||||||
id: 'zoom-in',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '+' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-out',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '-' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-reset',
|
|
||||||
category: 'interaction',
|
|
||||||
shortcut: { key: '0' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zoom-pan',
|
|
||||||
category: 'interaction',
|
|
||||||
kind: 'arrows',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel,
|
shiftLabel,
|
||||||
plusLabel,
|
plusLabel,
|
||||||
noShortcutLabel,
|
noShortcutLabel,
|
||||||
keyLabels,
|
|
||||||
}: {
|
}: {
|
||||||
controlLabel: string
|
controlLabel: string
|
||||||
commandLabel: string
|
commandLabel: string
|
||||||
@@ -34,12 +33,10 @@ export const formatShortcutLabelForSR = (
|
|||||||
shiftLabel: string
|
shiftLabel: string
|
||||||
plusLabel: string
|
plusLabel: string
|
||||||
noShortcutLabel: string
|
noShortcutLabel: string
|
||||||
// Spelled-out names for keys screen readers may skip or mispronounce.
|
|
||||||
keyLabels?: Record<string, string>
|
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!shortcut) return noShortcutLabel
|
if (!shortcut) return noShortcutLabel
|
||||||
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
|
const key = shortcut.key?.toUpperCase()
|
||||||
if (!key) return noShortcutLabel
|
if (!key) return noShortcutLabel
|
||||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||||
const altWord = isMacintosh() ? optionLabel : altLabel
|
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatVisual = useCallback(
|
const formatVisual = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
if (kind === 'arrows') {
|
|
||||||
return t('shortcutsPanel.visual.arrows')
|
|
||||||
}
|
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
||||||
@@ -26,9 +23,6 @@ export const useShortcutFormatting = () => {
|
|||||||
|
|
||||||
const formatForSR = useCallback(
|
const formatForSR = useCallback(
|
||||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||||
if (kind === 'arrows') {
|
|
||||||
return t('shortcutsPanel.sr.arrows')
|
|
||||||
}
|
|
||||||
if (code && kind === 'longPress') {
|
if (code && kind === 'longPress') {
|
||||||
const label = getKeyLabelFromCode(code)
|
const label = getKeyLabelFromCode(code)
|
||||||
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
||||||
@@ -41,10 +35,6 @@ export const useShortcutFormatting = () => {
|
|||||||
shiftLabel: t('shortcutsPanel.sr.shift'),
|
shiftLabel: t('shortcutsPanel.sr.shift'),
|
||||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||||
keyLabels: {
|
|
||||||
'+': t('shortcutsPanel.sr.plusKey'),
|
|
||||||
'-': t('shortcutsPanel.sr.minusKey'),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
|
|||||||
@@ -756,24 +756,6 @@
|
|||||||
"muteParticipant": "{{name}} stummschalten",
|
"muteParticipant": "{{name}} stummschalten",
|
||||||
"fullScreen": "Vollbild"
|
"fullScreen": "Vollbild"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
|
|
||||||
"zoomIn": "Vergrößern",
|
|
||||||
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
|
|
||||||
"zoomOut": "Verkleinern",
|
|
||||||
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Steuerung plus Mausrad",
|
|
||||||
"wheelShortcutMac": "Befehl plus Mausrad",
|
|
||||||
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
|
|
||||||
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
|
|
||||||
"fitToWindow": "An Fenster anpassen",
|
|
||||||
"fullScreen": "Vollbild",
|
|
||||||
"exitFullScreen": "Vollbild beenden",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder den Fokus zurück auf die Bildschirmfreigabe setzen und mit den Pfeiltasten im Bild navigieren.",
|
|
||||||
"fullScreenEntered": "Vollbild aktiviert",
|
|
||||||
"fullScreenExited": "Vollbild deaktiviert"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Tastenkürzel",
|
"title": "Tastenkürzel",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +775,7 @@
|
|||||||
"raise-hand": "Hand heben oder senken",
|
"raise-hand": "Hand heben oder senken",
|
||||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||||
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
||||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen",
|
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||||
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
|
|
||||||
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
|
|
||||||
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
|
|
||||||
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Steuerung",
|
"control": "Steuerung",
|
||||||
@@ -807,14 +785,10 @@
|
|||||||
"shift": "Umschalt",
|
"shift": "Umschalt",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Halte {{key}} gedrückt",
|
"hold": "Halte {{key}} gedrückt",
|
||||||
"arrows": "Pfeiltasten",
|
|
||||||
"plusKey": "Plus-Taste",
|
|
||||||
"minusKey": "Minus-Taste",
|
|
||||||
"noShortcut": "Kein Tastenkürzel"
|
"noShortcut": "Kein Tastenkürzel"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Halte {{key}} gedrückt",
|
"hold": "Halte {{key}} gedrückt"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -756,24 +756,6 @@
|
|||||||
"muteParticipant": "Mute {{name}}",
|
"muteParticipant": "Mute {{name}}",
|
||||||
"fullScreen": "Full screen"
|
"fullScreen": "Full screen"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Screen share zoom controls",
|
|
||||||
"zoomIn": "Zoom in",
|
|
||||||
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
|
|
||||||
"zoomOut": "Zoom out",
|
|
||||||
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Control plus scroll wheel",
|
|
||||||
"wheelShortcutMac": "Command plus scroll wheel",
|
|
||||||
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
|
|
||||||
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
|
|
||||||
"fitToWindow": "Fit to window",
|
|
||||||
"fullScreen": "Full screen",
|
|
||||||
"exitFullScreen": "Exit full screen",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Drag with mouse, or move focus back to the screen share and use arrow keys to navigate the picture.",
|
|
||||||
"fullScreenEntered": "Full screen enabled",
|
|
||||||
"fullScreenExited": "Full screen disabled"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Keyboard shortcuts",
|
"title": "Keyboard shortcuts",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +775,7 @@
|
|||||||
"raise-hand": "Raise or lower hand",
|
"raise-hand": "Raise or lower hand",
|
||||||
"toggle-chat": "Toggle chat",
|
"toggle-chat": "Toggle chat",
|
||||||
"toggle-participants": "Toggle participants",
|
"toggle-participants": "Toggle participants",
|
||||||
"open-shortcuts-settings": "Open shortcuts settings",
|
"open-shortcuts-settings": "Open shortcuts settings"
|
||||||
"zoom-in": "Zoom in on a shared screen",
|
|
||||||
"zoom-out": "Zoom out on a shared screen",
|
|
||||||
"zoom-reset": "Reset the shared screen zoom",
|
|
||||||
"zoom-pan": "Move around a zoomed shared screen"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Control",
|
"control": "Control",
|
||||||
@@ -807,14 +785,10 @@
|
|||||||
"shift": "Shift",
|
"shift": "Shift",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Hold {{key}}",
|
"hold": "Hold {{key}}",
|
||||||
"arrows": "Arrow keys",
|
|
||||||
"plusKey": "Plus key",
|
|
||||||
"minusKey": "Minus key",
|
|
||||||
"noShortcut": "No shortcut"
|
"noShortcut": "No shortcut"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Hold {{key}}",
|
"hold": "Hold {{key}}"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -756,24 +756,6 @@
|
|||||||
"muteParticipant": "Couper le micro de {{name}}",
|
"muteParticipant": "Couper le micro de {{name}}",
|
||||||
"fullScreen": "Plein écran"
|
"fullScreen": "Plein écran"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Contrôles de zoom du partage d'écran",
|
|
||||||
"zoomIn": "Zoomer",
|
|
||||||
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
|
|
||||||
"zoomOut": "Dézoomer",
|
|
||||||
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Contrôle plus molette",
|
|
||||||
"wheelShortcutMac": "Commande plus molette",
|
|
||||||
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
|
|
||||||
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
|
|
||||||
"fitToWindow": "Ajuster à la fenêtre",
|
|
||||||
"fullScreen": "Plein écran",
|
|
||||||
"exitFullScreen": "Quitter le plein écran",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Glissez avec la souris, ou revenez sur le partage d'écran et utilisez les touches fléchées pour naviguer dans l'image.",
|
|
||||||
"fullScreenEntered": "Plein écran activé",
|
|
||||||
"fullScreenExited": "Plein écran désactivé"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Raccourcis clavier",
|
"title": "Raccourcis clavier",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +775,7 @@
|
|||||||
"raise-hand": "Lever ou baisser la main",
|
"raise-hand": "Lever ou baisser la main",
|
||||||
"toggle-chat": "Afficher/Masquer le chat",
|
"toggle-chat": "Afficher/Masquer le chat",
|
||||||
"toggle-participants": "Afficher/Masquer les participants",
|
"toggle-participants": "Afficher/Masquer les participants",
|
||||||
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis",
|
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
|
||||||
"zoom-in": "Zoomer sur un écran partagé",
|
|
||||||
"zoom-out": "Dézoomer sur un écran partagé",
|
|
||||||
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
|
|
||||||
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Contrôle",
|
"control": "Contrôle",
|
||||||
@@ -807,14 +785,10 @@
|
|||||||
"shift": "Majuscule",
|
"shift": "Majuscule",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Maintenir {{key}}",
|
"hold": "Maintenir {{key}}",
|
||||||
"arrows": "Touches fléchées",
|
|
||||||
"plusKey": "Touche plus",
|
|
||||||
"minusKey": "Touche moins",
|
|
||||||
"noShortcut": "Aucun raccourci"
|
"noShortcut": "Aucun raccourci"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Maintenir {{key}}",
|
"hold": "Maintenir {{key}}"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
@@ -756,24 +756,6 @@
|
|||||||
"muteParticipant": "Demp {{name}}",
|
"muteParticipant": "Demp {{name}}",
|
||||||
"fullScreen": "Volledig scherm"
|
"fullScreen": "Volledig scherm"
|
||||||
},
|
},
|
||||||
"screenShareZoom": {
|
|
||||||
"toolbarLabel": "Zoombediening voor schermdeling",
|
|
||||||
"zoomIn": "Inzoomen",
|
|
||||||
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
|
|
||||||
"zoomOut": "Uitzoomen",
|
|
||||||
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
|
|
||||||
"wheelShortcut": "Control plus scrollwiel",
|
|
||||||
"wheelShortcutMac": "Command plus scrollwiel",
|
|
||||||
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
|
|
||||||
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
|
|
||||||
"fitToWindow": "Aanpassen aan venster",
|
|
||||||
"fullScreen": "Volledig scherm",
|
|
||||||
"exitFullScreen": "Volledig scherm verlaten",
|
|
||||||
"currentZoomLevel": "Zoom {{level}} %",
|
|
||||||
"panHint": "Zoom {{level}} %. Sleep met de muis, of zet de focus terug op de schermdeling en gebruik de pijltjestoetsen om door het beeld te navigeren.",
|
|
||||||
"fullScreenEntered": "Volledig scherm ingeschakeld",
|
|
||||||
"fullScreenExited": "Volledig scherm uitgeschakeld"
|
|
||||||
},
|
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Sneltoetsen",
|
"title": "Sneltoetsen",
|
||||||
"categories": {
|
"categories": {
|
||||||
@@ -793,11 +775,7 @@
|
|||||||
"raise-hand": "Hand opsteken of laten zakken",
|
"raise-hand": "Hand opsteken of laten zakken",
|
||||||
"toggle-chat": "Chat tonen/verbergen",
|
"toggle-chat": "Chat tonen/verbergen",
|
||||||
"toggle-participants": "Deelnemers tonen/verbergen",
|
"toggle-participants": "Deelnemers tonen/verbergen",
|
||||||
"open-shortcuts-settings": "Sneltoets-instellingen openen",
|
"open-shortcuts-settings": "Sneltoets-instellingen openen"
|
||||||
"zoom-in": "Inzoomen op een gedeeld scherm",
|
|
||||||
"zoom-out": "Uitzoomen op een gedeeld scherm",
|
|
||||||
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
|
|
||||||
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
|
|
||||||
},
|
},
|
||||||
"sr": {
|
"sr": {
|
||||||
"control": "Control",
|
"control": "Control",
|
||||||
@@ -807,14 +785,10 @@
|
|||||||
"shift": "Shift",
|
"shift": "Shift",
|
||||||
"plus": "plus",
|
"plus": "plus",
|
||||||
"hold": "Houd {{key}} ingedrukt",
|
"hold": "Houd {{key}} ingedrukt",
|
||||||
"arrows": "Pijltoetsen",
|
|
||||||
"plusKey": "Plus-toets",
|
|
||||||
"minusKey": "Min-toets",
|
|
||||||
"noShortcut": "Geen sneltoets"
|
"noShortcut": "Geen sneltoets"
|
||||||
},
|
},
|
||||||
"visual": {
|
"visual": {
|
||||||
"hold": "Houd {{key}} ingedrukt",
|
"hold": "Houd {{key}} ingedrukt"
|
||||||
"arrows": "↑ ↓ ← →"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fullScreenWarning": {
|
"fullScreenWarning": {
|
||||||
|
|||||||
Reference in New Issue
Block a user