mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-29 22:19:08 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 609ea778f1 | |||
| a050134a48 | |||
| f7f793bc0c | |||
| f4aecdaf5a | |||
| 9a2ad63524 | |||
| 67f9e54784 | |||
| 5d3255ddbc | |||
| d89b01b681 | |||
| 660c0ed684 | |||
| 8d980192c8 | |||
| de1f7158f5 | |||
| 6e17c6533c | |||
| 27e32c0370 | |||
| 6d4403d4fa | |||
| 37ae308825 | |||
| 16fd2dc4e8 | |||
| 9791a8a3b2 | |||
| 5b0dece79b | |||
| 96135a0263 | |||
| ce2e2a4d64 | |||
| e5dc9c2f15 | |||
| e97eab9b5e | |||
| 436b3dc9df | |||
| 6ea2a85810 | |||
| 3d1ea88e8f | |||
| beb74af574 | |||
| c785b4a627 | |||
| a9a4246abb | |||
| 3cf7f60eaa | |||
| bf215f1513 | |||
| 75836fc817 | |||
| 1affe65b4a | |||
| c34ecbd3ef | |||
| 78960d9769 | |||
| 41d07d36ee | |||
| f7386e1741 | |||
| f1da04eb27 | |||
| 2970420b84 |
@@ -86,3 +86,6 @@ docker/livekit/rootCA.pem
|
||||
|
||||
# Frontend rollup-plugin-visualizer
|
||||
/src/frontend/rollup-plugin-visualizer/*
|
||||
|
||||
# NixOS
|
||||
.devenv
|
||||
|
||||
@@ -11,9 +11,16 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- ✨(backend) make the LiveKit default video codec configurable
|
||||
- 🔧(dev) add support for Bureautix workstations
|
||||
- ✨(frontend) add screen share zoom controls #1498
|
||||
- ✨(frontend) open screen share in a separate window #1734
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(backend) remove unused API viewset and permission helpers
|
||||
- 🔊(backend) pin the dockerflow logger level to WARNING
|
||||
- 🚑️(summary) serve health endpoints with the dockerflow router
|
||||
- ♻️(backend) serve the dockerflow views early in the middleware stack
|
||||
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||
- 🔇(backend) silence expected 401 warnings on /me
|
||||
- 🔇(backend) silence noisy request summary info logs
|
||||
@@ -22,15 +29,26 @@ and this project adheres to
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
|
||||
- 🔖(helm) release chart 0.0.28
|
||||
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(helm) probe liveness on **lbheartbeat** and readiness on **heartbeat**
|
||||
- 🐛(helm) render periodSeconds and failureThreshold on probes
|
||||
- 🐛(backend) report the app release to Sentry instead of "NA"
|
||||
- 🐛(frontend) play the waiting room notification sound on every arrival
|
||||
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
|
||||
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🔒️(backend) reject inactive users in resource server backend
|
||||
- 🐛(frontend) fix file permissions in the Docker image
|
||||
- 🚸(frontend) inform user that recording waits until a track is published
|
||||
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
|
||||
- 🐛(backend) handle failed and aborted egresses
|
||||
- 🩹(frontend) notify participants when a recording fails or is aborted
|
||||
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
|
||||
|
||||
## [1.31.0] - 2026-09-08
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Django Meet
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
FROM python:3.13.15-alpine3.24 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
@@ -292,7 +292,7 @@ shell: ## connect to database shell
|
||||
# -- Database
|
||||
|
||||
dbshell: ## connect to database shell
|
||||
docker compose exec app-dev python manage.py dbshell
|
||||
@$(COMPOSE_EXEC_APP) python manage.py dbshell
|
||||
.PHONY: dbshell
|
||||
|
||||
resetdb: FLUSH_ARGS ?=
|
||||
|
||||
+1
-2
@@ -5,7 +5,7 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
|
||||
COMPOSE_PROJECT="meet"
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ function _docker_compose() {
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_docker_compose "$@"
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1778705847,
|
||||
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
|
||||
"revCount": 6569,
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1789542786,
|
||||
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
|
||||
"ref": "nixos-26.05",
|
||||
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
},
|
||||
"original": {
|
||||
"ref": "nixos-26.05",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
# =============================================================================
|
||||
# devenv.nix — La Suite Meet ("Visio") developer environment
|
||||
# =============================================================================
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
python = pkgs.python313;
|
||||
nodejs = pkgs.nodejs_22;
|
||||
|
||||
backendDir = "src/backend";
|
||||
agentsDir = "src/agents";
|
||||
summaryDir = "src/summary";
|
||||
frontendDir = "src/frontend";
|
||||
|
||||
readDotEnv =
|
||||
file:
|
||||
let
|
||||
lines = lib.splitString "\n" (builtins.readFile file);
|
||||
unquote =
|
||||
v:
|
||||
let
|
||||
len = builtins.stringLength v;
|
||||
in
|
||||
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else
|
||||
v;
|
||||
parseLine =
|
||||
line:
|
||||
let
|
||||
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
|
||||
in
|
||||
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
|
||||
in
|
||||
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
|
||||
|
||||
# Reuse existing .env
|
||||
dotEnv =
|
||||
(readDotEnv ./env.d/development/common.dist)
|
||||
// (readDotEnv ./env.d/development/postgresql.dist);
|
||||
|
||||
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
|
||||
in
|
||||
{
|
||||
options.meet = {
|
||||
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
|
||||
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
|
||||
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
|
||||
};
|
||||
|
||||
config = {
|
||||
# Profile can be activated with devenv --profile <profile> shell
|
||||
profiles = {
|
||||
agents.module = {
|
||||
meet.agents.enable = true;
|
||||
};
|
||||
summary.module = {
|
||||
meet.summary.enable = true;
|
||||
};
|
||||
k8s.module = {
|
||||
meet.k8s.enable = true;
|
||||
};
|
||||
};
|
||||
languages.python = {
|
||||
enable = true;
|
||||
package = python;
|
||||
directory = backendDir;
|
||||
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
|
||||
|
||||
libraries = [
|
||||
"${config.devenv.dotfile}/profile"
|
||||
pkgs.file
|
||||
pkgs.zlib
|
||||
pkgs.libffi
|
||||
pkgs.openssl
|
||||
];
|
||||
|
||||
uv.enable = true;
|
||||
uv.sync.enable = false;
|
||||
venv.enable = false;
|
||||
lsp.enable = true;
|
||||
};
|
||||
|
||||
languages.javascript = {
|
||||
enable = true;
|
||||
package = nodejs;
|
||||
directory = frontendDir;
|
||||
|
||||
npm.enable = true;
|
||||
yarn.enable = true;
|
||||
corepack.enable = false;
|
||||
};
|
||||
|
||||
languages.typescript.enable = false;
|
||||
languages.nix.enable = true;
|
||||
|
||||
packages =
|
||||
with pkgs;
|
||||
[
|
||||
gnumake
|
||||
file
|
||||
shared-mime-info
|
||||
gettext
|
||||
postgresql_16
|
||||
git
|
||||
curl
|
||||
jq
|
||||
podman
|
||||
podman-compose
|
||||
docker-client
|
||||
]
|
||||
|
||||
# -- LiveKit agents
|
||||
++ lib.optionals config.meet.agents.enable [
|
||||
glib
|
||||
portaudio
|
||||
livekit-cli
|
||||
]
|
||||
|
||||
# -- summary service
|
||||
++ lib.optionals config.meet.summary.enable [
|
||||
redis
|
||||
]
|
||||
|
||||
# -- Kubernetes tools
|
||||
++ lib.optionals config.meet.k8s.enable [
|
||||
kubectl
|
||||
kubernetes-helm
|
||||
helmfile
|
||||
tilt
|
||||
kind
|
||||
mkcert
|
||||
];
|
||||
|
||||
env = sharedEnv // {
|
||||
UV_LINK_MODE = "copy";
|
||||
|
||||
PYTHONDONTWRITEBYTECODE = "1";
|
||||
PYTHONUNBUFFERED = "1";
|
||||
|
||||
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
|
||||
|
||||
COMPOSE_PROJECT_NAME = "meet";
|
||||
|
||||
DJANGO_DATA_DIR = "${config.devenv.root}/data";
|
||||
|
||||
# Database / Pgsql
|
||||
DB_HOST = "127.0.0.1";
|
||||
DB_PORT = "15432";
|
||||
PGHOST = "127.0.0.1";
|
||||
PGPORT = "15432";
|
||||
PGDATABASE = sharedEnv.DB_NAME;
|
||||
PGUSER = sharedEnv.DB_USER;
|
||||
PGPASSWORD = sharedEnv.DB_PASSWORD;
|
||||
|
||||
REDIS_URL = "redis://127.0.0.1:6379/1";
|
||||
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
||||
|
||||
# S3 / MinIO
|
||||
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
|
||||
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
|
||||
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
|
||||
|
||||
# summary service
|
||||
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
|
||||
SUMMARY_SERVICE_VERSION = "2";
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST = "127.0.0.1";
|
||||
};
|
||||
|
||||
scripts = {
|
||||
|
||||
meet-venv = {
|
||||
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
|
||||
exec = ''
|
||||
set -euo pipefail
|
||||
cd "$DEVENV_ROOT"
|
||||
|
||||
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
|
||||
( cd "${backendDir}" && uv sync --locked --all-groups )
|
||||
|
||||
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
|
||||
( cd "${agentsDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
|
||||
( cd "${summaryDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo
|
||||
echo "Synced the following virtualenvs successfully:"
|
||||
echo " ${backendDir}/.venv"
|
||||
echo " ${agentsDir}/.venv"
|
||||
echo " ${summaryDir}/.venv"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
# Make podman socket accessible in order to launch regular docker commands.
|
||||
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
|
||||
case "''${MEET_PODMAN_SOCKET:-1}" in
|
||||
0|false|no|off) ;;
|
||||
*)
|
||||
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
|
||||
unset _rundir
|
||||
;;
|
||||
esac
|
||||
|
||||
# Compose files to merge
|
||||
_compose_dir="${config.devenv.root}/docker/compose.d"
|
||||
_compose_files="${config.devenv.root}/compose.yml"
|
||||
|
||||
export DOCKER_USER="$(id -u):$(id -g)"
|
||||
|
||||
case "''${DOCKER_HOST:-}" in
|
||||
*podman*)
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
|
||||
|
||||
# Build images with Podman/Buildah rather than BuildKit. `docker
|
||||
# compose build` otherwise has buildx boot a moby/buildkit container,
|
||||
# and that container lands in its own network namespace with neither
|
||||
# the proxy in its environment nor any route to it.
|
||||
# Buildah has neither problem: base images are resolved by the Podman systemd
|
||||
# service, which inherits the proxy from its systemd socket activated unit, and
|
||||
# RUN steps execute in the *host* network namespace
|
||||
|
||||
export DOCKER_BUILDKIT=0
|
||||
export COMPOSE_BAKE=false
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# Apply Bureautix override
|
||||
if [ -n "''${http_proxy:-}" ]; then
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
|
||||
fi
|
||||
|
||||
export COMPOSE_FILE="$_compose_files"
|
||||
unset _compose_dir _compose_files
|
||||
|
||||
# Make binaries accessible
|
||||
for _d in \
|
||||
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
|
||||
do
|
||||
[ -d "$_d" ] && export PATH="$_d:$PATH"
|
||||
done
|
||||
unset _d
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
|
||||
devenv:
|
||||
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
|
||||
@@ -0,0 +1,48 @@
|
||||
# Bureautix proxy overrides
|
||||
#
|
||||
# Builds submitted through the Docker API of the Podman service get none of
|
||||
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitely
|
||||
# otherwise all connections fail during the build.
|
||||
|
||||
x-proxy-vars: &proxy-vars
|
||||
http_proxy: ${http_proxy:-}
|
||||
https_proxy: ${https_proxy:-}
|
||||
no_proxy: ${no_proxy:-}
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
multi-user-transcriber-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-summary-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-transcribe:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-summarize:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||
keycloak:
|
||||
ports: !override
|
||||
- "8081:8080"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Rootless Podman override for compose.yml.
|
||||
#
|
||||
# Rootless Podman maps container UID 0 to the host user and every other
|
||||
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
|
||||
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
|
||||
# subuid and make every bind mount effectively read-only.
|
||||
#
|
||||
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
|
||||
# container instead, so DOCKER_USER keeps its Docker value and files written
|
||||
# through a bind mount are owned by the host user on both sides.
|
||||
#
|
||||
# Only the services that mount the worktree and run as DOCKER_USER are listed.
|
||||
|
||||
x-keep-id: &keep-id
|
||||
userns_mode: keep-id
|
||||
|
||||
services:
|
||||
app-dev:
|
||||
<<: *keep-id
|
||||
celery-dev:
|
||||
<<: *keep-id
|
||||
minio:
|
||||
<<: *keep-id
|
||||
node:
|
||||
<<: *keep-id
|
||||
crowdin:
|
||||
<<: *keep-id
|
||||
metadata-collector-dev:
|
||||
<<: *keep-id
|
||||
multi-user-transcriber-dev:
|
||||
<<: *keep-id
|
||||
app-summary-dev:
|
||||
<<: *keep-id
|
||||
@@ -57,7 +57,8 @@ RUN npx webpack --mode production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk del curl
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
@@ -21,3 +21,10 @@ turn:
|
||||
- 192.168.0.0/16
|
||||
- 172.16.0.0/12
|
||||
|
||||
rtc:
|
||||
node_ip: 127.0.0.1
|
||||
advertise_internal_ip: true
|
||||
udp_port: 7882
|
||||
tcp_port: 7881
|
||||
use_external_ip: false
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.0",
|
||||
"i18next": "26.4.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9367,9 +9367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
||||
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
||||
"version": "26.4.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz",
|
||||
"integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.0",
|
||||
"i18next": "26.4.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
FROM python:3.14.6-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -12,10 +12,6 @@ from ..services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
@@ -27,15 +23,6 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
|
||||
@@ -95,60 +95,6 @@ from .feature_flag import FeatureFlag
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
|
||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
||||
"""
|
||||
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, file):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, file + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(file)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of files for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
||||
# to the object pk, it is used within get_object method.
|
||||
lookup_url_kwargs = (
|
||||
self.lookup_url_kwargs[:-1]
|
||||
if self.lookup_url_kwargs
|
||||
else self.lookup_fields[:-1]
|
||||
)
|
||||
|
||||
filter_kwargs = {}
|
||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
||||
if lookup_url_kwarg not in self.kwargs:
|
||||
raise KeyError(
|
||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
||||
"set the `.lookup_fields` attribute on the view correctly."
|
||||
)
|
||||
|
||||
filter_kwargs.update(
|
||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
||||
)
|
||||
|
||||
return queryset.filter(**filter_kwargs)
|
||||
|
||||
|
||||
class SerializerPerActionMixin:
|
||||
"""
|
||||
A mixin to allow to define serializer classes for each action.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 16:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_user_default_room_access_level_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -58,6 +58,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
SAVED = "saved", _("Saved")
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED = "failed", _("Failed")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
@@ -79,17 +80,13 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.FAILED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_unsuccessful(cls, status):
|
||||
"""Determine if the recording status represents an unsuccessful state."""
|
||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
"""Recording mode choices."""
|
||||
@@ -582,6 +579,7 @@ class Recording(BaseModel):
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED: Egress failed mid-recording
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
@@ -8,3 +8,50 @@ class FileExtension(Enum):
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class RecordingWorkerEvent(Enum):
|
||||
"""Lifecycle events a recording worker reports about a recording.
|
||||
|
||||
It is intended to be free of SFU-specific vocabulary.
|
||||
"""
|
||||
|
||||
# The worker accepted the request but is not recording yet.
|
||||
STARTING = "starting"
|
||||
# The worker is recording.
|
||||
STARTED = "started"
|
||||
# The worker stopped recording and is flushing the media file.
|
||||
SAVING = "saving"
|
||||
|
||||
# The recording ended, its media file is available.
|
||||
COMPLETED = "completed"
|
||||
# The recording ended on its configured limit, its media file is available.
|
||||
LIMIT_REACHED = "limit reached"
|
||||
# The worker stopped before it ever started recording, there is no media file.
|
||||
ABORTED = "aborted"
|
||||
# The worker hit a runtime error once recording had started; its media file
|
||||
# may be available.
|
||||
FAILED = "failed"
|
||||
|
||||
@classmethod
|
||||
def is_terminal(cls, event):
|
||||
"""Determine if the event ends the recording's lifecycle (successful or not)."""
|
||||
|
||||
return event in TERMINAL_EVENTS
|
||||
|
||||
|
||||
SUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
}
|
||||
)
|
||||
|
||||
UNSUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.ABORTED,
|
||||
RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
)
|
||||
|
||||
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Recording-related LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
"""Recording-related Events Service"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.enums import (
|
||||
UNSUCCESSFUL_EVENTS,
|
||||
RecordingWorkerEvent,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
@@ -26,22 +26,110 @@ class RecordingNotSavableError(Exception):
|
||||
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||
|
||||
|
||||
# Notification sent to the room's participants, per event and recording mode.
|
||||
NOTIFICATION_PREFIXES = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcription",
|
||||
}
|
||||
NOTIFICATION_SUFFIXES = {
|
||||
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
|
||||
RecordingWorkerEvent.FAILED: "Failed",
|
||||
RecordingWorkerEvent.ABORTED: "Aborted",
|
||||
}
|
||||
|
||||
|
||||
def get_notification_type(recording_mode, event):
|
||||
"""Generate corresponding notification type string."""
|
||||
try:
|
||||
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
# Recording status in the room's metadata, per event.
|
||||
ROOM_METADATA_RECORDING_STATUSES = {
|
||||
RecordingWorkerEvent.STARTED: "started",
|
||||
RecordingWorkerEvent.SAVING: "saving",
|
||||
}
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
"""Handles recording-related worker events.
|
||||
|
||||
Two entry points: `handle_update` for the events a running recording
|
||||
reports, and `handle_terminal_event` for the one ending it.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
def log_worker_error(recording, event, error=None, error_code=None):
|
||||
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
|
||||
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
log = logger.error
|
||||
elif event == RecordingWorkerEvent.ABORTED:
|
||||
log = logger.info
|
||||
else:
|
||||
return
|
||||
|
||||
log(
|
||||
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.room.id,
|
||||
recording.mode,
|
||||
error or "no error reported",
|
||||
error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
notification_type = get_notification_type(recording_mode, event)
|
||||
if not notification_type:
|
||||
logger.warning(
|
||||
"Could not find notification type for: "
|
||||
"room=%s, recording_id=%s, mode=%s, event=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording_mode,
|
||||
event.value,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording {event.value} (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _log_notification_failure(recording, event: RecordingWorkerEvent):
|
||||
"""Log a participant notification error on an unsuccessful recording."""
|
||||
|
||||
logger.exception(
|
||||
"Failed to notify participants that recording %s %s (room=%s)",
|
||||
recording.id,
|
||||
event.value,
|
||||
recording.room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Handle non-terminal worker events and sync recording state to room metadata.
|
||||
|
||||
Terminal events are dispatched through `handle_terminal_event` instead.
|
||||
"""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
|
||||
status_mapping = {
|
||||
api.EgressStatus.EGRESS_ACTIVE: "started",
|
||||
api.EgressStatus.EGRESS_ENDING: "saving",
|
||||
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
||||
}
|
||||
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event)
|
||||
if recording_status:
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
@@ -55,42 +143,113 @@ class RecordingEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording: Recording):
|
||||
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Run the appropriate handlers for a terminal event, given the recording's state."""
|
||||
|
||||
if not RecordingWorkerEvent.is_terminal(event):
|
||||
logger.warning(
|
||||
"Ignoring non-terminal event %s dispatched as a terminal event "
|
||||
"for recording %s.",
|
||||
event.value,
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
if event in UNSUCCESSFUL_EVENTS:
|
||||
self._flag_unsuccessful_recording(recording, event)
|
||||
else:
|
||||
self._save_successful_recording(recording, event)
|
||||
|
||||
def _flag_unsuccessful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Persist the outcome of a recording the worker announced as unsuccessful."""
|
||||
|
||||
# Aborted
|
||||
if event == RecordingWorkerEvent.ABORTED:
|
||||
if recording.status == models.RecordingStatusChoices.ACTIVE:
|
||||
self._apply_outcome(recording, event, self._handle_aborted)
|
||||
return
|
||||
|
||||
# Failed
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
if recording.is_savable():
|
||||
self._apply_outcome(recording, event, self._handle_failed)
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.status,
|
||||
)
|
||||
|
||||
def _save_successful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Save a recording whose media file the worker made available."""
|
||||
|
||||
# Limit reached
|
||||
if (
|
||||
event == RecordingWorkerEvent.LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
self._apply_outcome(recording, event, self._handle_limit_reached)
|
||||
|
||||
try:
|
||||
self._handle_successful(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on a completed recording "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
def _apply_outcome(
|
||||
self, recording: Recording, event: RecordingWorkerEvent, handler
|
||||
):
|
||||
"""Keep notification failure non-fatal."""
|
||||
|
||||
try:
|
||||
handler(recording)
|
||||
except RecordingEventsError:
|
||||
self._log_notification_failure(recording, event)
|
||||
|
||||
@classmethod
|
||||
def _handle_limit_reached(cls, recording: Recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
recording.save()
|
||||
|
||||
notification_mapping = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||
}
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
|
||||
|
||||
notification_type = notification_mapping.get(recording.mode)
|
||||
if not notification_type:
|
||||
return
|
||||
@classmethod
|
||||
def _handle_failed(cls, recording: Recording):
|
||||
"""Set recording status to failed, matching the worker event, and notify participants.
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
logger.exception(
|
||||
"Failed to notify participants about recording limit reached: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
FAILED: used when an actual runtime/pipeline error occurs after the
|
||||
recording has started
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.FAILED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
|
||||
|
||||
@classmethod
|
||||
def _handle_aborted(cls, recording: Recording):
|
||||
"""Set recording status to aborted, matching the worker event, and notify participants.
|
||||
|
||||
ABORTED: used when the worker stops before it ever became
|
||||
active/recording
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.ABORTED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
|
||||
|
||||
@staticmethod
|
||||
def handle_complete(recording: Recording):
|
||||
def _handle_successful(recording: Recording):
|
||||
"""Notify external services and save recording."""
|
||||
|
||||
if not recording.is_savable():
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
@@ -10,6 +12,8 @@ from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseEgressService:
|
||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||
@@ -49,6 +53,22 @@ class BaseEgressService:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@staticmethod
|
||||
def _log_egress_error(response, event: str):
|
||||
"""Log the reason LiveKit reported an unsuccessful egress on stop.
|
||||
|
||||
Mirrors the logging done in the 'egress_ended' webhook. The
|
||||
StopEgress response carries the same error fields.
|
||||
"""
|
||||
logger.error(
|
||||
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
|
||||
event,
|
||||
response.egress_id,
|
||||
livekit_api.EgressStatus.Name(response.status),
|
||||
response.error or "no error reported",
|
||||
response.error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
The StopEgressRequest is shared among all types of egress,
|
||||
@@ -66,14 +86,26 @@ class BaseEgressService:
|
||||
"LiveKit response is missing the recording status."
|
||||
)
|
||||
|
||||
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
||||
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||
return "STOPPED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
|
||||
return "STOPPED"
|
||||
|
||||
# Cases below should be very infrequent as status changes should be
|
||||
# received and processed by `handle_ended`, thus `stop` would not
|
||||
# be called (unless failure and stop are very close in time).
|
||||
# We therefore accept not to notify the user in this code branch.
|
||||
# This could be fixed in a future refactoring.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
self._log_egress_error(response, "aborted")
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
|
||||
self._log_egress_error(response, "failed")
|
||||
return "FAILED"
|
||||
|
||||
self._log_egress_error(response, "failed to stop")
|
||||
return "FAILED_TO_STOP"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
|
||||
@@ -12,15 +12,12 @@ from django.conf import settings
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
@@ -84,6 +81,30 @@ class LiveKitWebhookEventType(Enum):
|
||||
INGRESS_ENDED = "ingress_ended"
|
||||
|
||||
|
||||
# LiveKit egress statuses mapped to recording worker event statuses
|
||||
EGRESS_STATUS_TO_RECORDING_EVENT = {
|
||||
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
|
||||
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
|
||||
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
|
||||
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
|
||||
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
|
||||
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def to_recording_event(egress_status):
|
||||
"""Translate a LiveKit egress status into a recording worker event."""
|
||||
|
||||
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
|
||||
if event is None:
|
||||
logger.warning(
|
||||
"Unmapped LiveKit egress status '%s', ignoring the event.",
|
||||
egress_status,
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
class LiveKitEventsService:
|
||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||
|
||||
@@ -173,12 +194,20 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
egress_status = data.egress_info.status
|
||||
self.recording_events.handle_update(recording, egress_status)
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
self.recording_events.handle_update(recording, event)
|
||||
|
||||
def _handle_egress_ended(self, data):
|
||||
"""Handle 'egress_ended' event."""
|
||||
"""Handle 'egress_ended' event.
|
||||
|
||||
Egress ended is sent with one of these statuses:
|
||||
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
|
||||
"""
|
||||
|
||||
# Fetch recording
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
@@ -188,6 +217,17 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
|
||||
# Log if/why the recording failed
|
||||
self.recording_events.log_worker_error(
|
||||
recording,
|
||||
event,
|
||||
error=data.egress_info.error,
|
||||
error_code=data.egress_info.error_code,
|
||||
)
|
||||
|
||||
# Update room
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
RoomManagement.update_metadata(
|
||||
@@ -201,38 +241,17 @@ class LiveKitEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
# Stop metadata collector
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
try:
|
||||
MetadataCollectorService().stop(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to stop the MetadataCollectorService")
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
try:
|
||||
self.recording_events.handle_limit_reached(recording)
|
||||
except RecordingEventsError as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
if event is None:
|
||||
return
|
||||
|
||||
# Finalize the recording, the egress has uploaded the file to the storage
|
||||
if data.egress_info.status in [
|
||||
api.EgressStatus.EGRESS_COMPLETE,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
]:
|
||||
try:
|
||||
self.recording_events.handle_complete(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on egress complete "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
self.recording_events.handle_terminal_event(recording, event)
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
|
||||
@@ -76,10 +76,6 @@ class RoomManagement:
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
Test RecordingEventsService service.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=redefined-outer-name,protected-access
|
||||
|
||||
import logging
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RecordingFactory
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -34,10 +39,10 @@ def service():
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_limit_reached stops recording and notifies participants."""
|
||||
"""Test _handle_limit_reached stops recording and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service.handle_limit_reached(recording)
|
||||
service._handle_limit_reached(recording)
|
||||
|
||||
assert recording.status == "stopped"
|
||||
mock_notify.assert_called_once_with(
|
||||
@@ -48,13 +53,69 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingLimitReached"),
|
||||
("transcript", "transcriptionLimitReached"),
|
||||
("screen_recording", "screenRecordingFailed"),
|
||||
("transcript", "transcriptionFailed"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
||||
def test_handle_failed_success(mock_notify, mode, notification_type, service):
|
||||
"""Test _handle_failed marks recording as failed and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service._handle_failed(recording)
|
||||
|
||||
assert recording.status == "failed"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingAborted"),
|
||||
("transcript", "transcriptionAborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
|
||||
"""Test _handle_aborted marks recording as aborted and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service._handle_aborted(recording)
|
||||
|
||||
assert recording.status == "aborted"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_prefix"),
|
||||
(("screen_recording", "screenRecording"), ("transcript", "transcription")),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("handler", "expected_status", "event", "notification_suffix"),
|
||||
(
|
||||
("_handle_limit_reached", "stopped", "limit reached", "LimitReached"),
|
||||
("_handle_failed", "failed", "failed", "Failed"),
|
||||
("_handle_aborted", "aborted", "aborted", "Aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
handler,
|
||||
expected_status,
|
||||
event,
|
||||
notification_suffix,
|
||||
mode,
|
||||
notification_prefix,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handlers raise RecordingEventsError when notifying participants fails,
|
||||
while still applying the recording status of their event.
|
||||
"""
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
@@ -62,14 +123,15 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
||||
|
||||
with pytest.raises(
|
||||
RecordingEventsError,
|
||||
match=r"Failed to notify participants in room '.+' "
|
||||
r"about recording limit reached \(recording_id=.+\)",
|
||||
match=rf"Failed to notify participants in room '.+' "
|
||||
rf"about recording {event} \(recording_id=.+\)",
|
||||
):
|
||||
service.handle_limit_reached(recording)
|
||||
getattr(service, handler)(recording)
|
||||
|
||||
assert recording.status == "stopped"
|
||||
assert recording.status == expected_status
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
|
||||
)
|
||||
|
||||
|
||||
@@ -82,19 +144,19 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def test_handle_successful_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
expected_status,
|
||||
status,
|
||||
service,
|
||||
):
|
||||
"""Test handle_complete notifies external services and saves a savable recording."""
|
||||
"""Test _handle_successful notifies external services and saves a savable recording."""
|
||||
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
service.handle_complete(recording)
|
||||
service._handle_successful(recording)
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
@@ -104,23 +166,293 @@ def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments,
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
||||
[
|
||||
"initiated",
|
||||
"saved",
|
||||
"notification_succeeded",
|
||||
"aborted",
|
||||
"failed",
|
||||
"failed_to_start",
|
||||
],
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_complete_non_savable_recording(
|
||||
def test_handle_successful_non_savable_recording(
|
||||
mock_notify_external_services, status, service
|
||||
):
|
||||
"""Test handle_complete refuses recordings that are already saved or in error."""
|
||||
"""Test _handle_successful refuses recordings that are already saved or in error."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
with pytest.raises(RecordingNotSavableError):
|
||||
service.handle_complete(recording)
|
||||
service._handle_successful(recording)
|
||||
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "recording_status"),
|
||||
(
|
||||
(RecordingWorkerEvent.STARTED, "started"),
|
||||
(RecordingWorkerEvent.SAVING, "saving"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_syncs_room_metadata(
|
||||
mock_update_metadata, event, recording_status, service
|
||||
):
|
||||
"""Test handle_update updates the room's metadata."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
service.handle_update(recording, event)
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": recording_status}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
RecordingWorkerEvent.ABORTED,
|
||||
RecordingWorkerEvent.FAILED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_ignores_events_without_a_metadata_status(
|
||||
mock_update_metadata, event, service
|
||||
):
|
||||
"""Test handle_update doesn't update metadata for events it doesn't match."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
service.handle_update(recording, event)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "initial_status", "expected_status", "notification_type"),
|
||||
(
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
|
||||
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
|
||||
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
|
||||
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
|
||||
(RecordingWorkerEvent.FAILED, "stopped", "failed", "Failed"),
|
||||
(RecordingWorkerEvent.FAILED, "aborted", "aborted", None),
|
||||
(RecordingWorkerEvent.COMPLETED, "active", "saved", None),
|
||||
(RecordingWorkerEvent.COMPLETED, "saved", "saved", None),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_dispatches_on_event_and_status( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
event,
|
||||
initial_status,
|
||||
expected_status,
|
||||
notification_type,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handle_terminal_event chooses the right handler from the event and status."""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
|
||||
recording = RecordingFactory(status=initial_status, mode="screen_recording")
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
if notification_type is None:
|
||||
mock_notify.assert_not_called()
|
||||
else:
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": f"screenRecording{notification_type}"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.STARTED,
|
||||
RecordingWorkerEvent.SAVING,
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_ignores_non_terminal_events(
|
||||
mock_notify, mock_notify_external_services, event, service, caplog
|
||||
):
|
||||
"""Test handle_terminal_event refuses non-terminal events."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
assert f"Ignoring non-terminal event {event.value}" in caplog.text
|
||||
mock_notify.assert_not_called()
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected_status"),
|
||||
(
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
|
||||
(RecordingWorkerEvent.ABORTED, "aborted"),
|
||||
(RecordingWorkerEvent.FAILED, "failed"),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_survives_a_notification_failure( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
event,
|
||||
expected_status,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handle_terminal_event logs a notification failure instead of raising.
|
||||
|
||||
The recording status must still be persisted: participants missing their
|
||||
notification should not disturb recording.
|
||||
"""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
assert f"Failed to notify participants that recording {recording.id}" in caplog.text
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["failed_to_start", "aborted", "failed", "failed_to_stop", "saved", "initiated"],
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_terminal_event_ignores_a_non_savable_recording(
|
||||
mock_notify_external_services, status, service, caplog
|
||||
):
|
||||
"""Test handle_terminal_event handles a redelivered event idempotently.
|
||||
|
||||
A terminal event may be redelivered for an already finalized recording;
|
||||
this must not raise, otherwise the webhook would 500 and be retried.
|
||||
"""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service.handle_terminal_event(recording, RecordingWorkerEvent.COMPLETED)
|
||||
|
||||
assert f"Recording {recording.id} is not savable" in caplog.text
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_survives_a_metadata_failure(
|
||||
mock_update_metadata, service, caplog
|
||||
):
|
||||
"""Test handle_update logs a metadata failure instead of raising."""
|
||||
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error updating")
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.handle_update(recording, RecordingWorkerEvent.SAVING)
|
||||
|
||||
assert "Failed to update room's metadata" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected_level"),
|
||||
(
|
||||
(RecordingWorkerEvent.ABORTED, logging.INFO),
|
||||
(RecordingWorkerEvent.FAILED, logging.ERROR),
|
||||
),
|
||||
)
|
||||
def test_log_worker_error_reports_an_unsuccessful_event(
|
||||
event, expected_level, service, caplog
|
||||
):
|
||||
"""Test log_worker_error records the reason the recording did not succeed."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode="screen_recording")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service.log_worker_error(
|
||||
recording, event, error="could not connect to the room", error_code=500
|
||||
)
|
||||
|
||||
assert (
|
||||
f"Recording worker reported {event.value} for recording {recording.id}"
|
||||
in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
worker_logs = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.name == "core.recording.services.recording_events"
|
||||
]
|
||||
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.STARTED,
|
||||
RecordingWorkerEvent.SAVING,
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
None,
|
||||
),
|
||||
)
|
||||
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
|
||||
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service.log_worker_error(recording, event, error="some error", error_code=500)
|
||||
|
||||
assert "Recording worker reported" not in caplog.text
|
||||
|
||||
@@ -224,6 +224,7 @@ def test_api_recording_retrieve_expired(settings):
|
||||
RecordingStatusChoices.INITIATED,
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
|
||||
@@ -4,6 +4,7 @@ Test worker service classes.
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -154,9 +155,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
||||
"response_status,expected_result",
|
||||
[
|
||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
|
||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
||||
@@ -175,6 +176,32 @@ def test_base_egress_stop_with_status(service, response_status, expected_result)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_status,event",
|
||||
[
|
||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "failed"),
|
||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "failed to stop"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_stop_logs_livekit_error(service, response_status, event, caplog):
|
||||
"""Should log the reason LiveKit reported for an unsuccessful stop."""
|
||||
mock_response = Mock(
|
||||
status=response_status,
|
||||
egress_id="test_worker_id",
|
||||
error="could not connect to the room",
|
||||
error_code=500,
|
||||
)
|
||||
service._handle_request = Mock(return_value=mock_response)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.stop("test_worker_id")
|
||||
|
||||
assert f"Egress {event} on stop (egress_id=test_worker_id" in caplog.text
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
|
||||
|
||||
def test_base_egress_stop_missing_status(service):
|
||||
"""Test stop method when response is missing status"""
|
||||
# Mock _handle_request with missing status
|
||||
|
||||
@@ -3,6 +3,7 @@ Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -10,13 +11,16 @@ import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
from core.services.livekit_events import (
|
||||
EGRESS_STATUS_TO_RECORDING_EVENT,
|
||||
ActionFailedError,
|
||||
AuthenticationError,
|
||||
InvalidPayloadError,
|
||||
LiveKitEventsService,
|
||||
api,
|
||||
to_recording_event,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
@@ -77,7 +81,7 @@ def test_initialization(
|
||||
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
"""Should successfully stop recording and notify all participant."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -104,7 +108,6 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
|
||||
(
|
||||
(EgressStatus.EGRESS_ACTIVE, "started"),
|
||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@@ -125,29 +128,6 @@ def test_handle_egress_updated_success(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="initiated")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
@@ -180,33 +160,38 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
"""Should still stop and save the recording when notifying participants fails."""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError,
|
||||
match=r"Failed to process limit reached event for recording .+",
|
||||
):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": "screenRecordingLimitReached"},
|
||||
)
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@@ -232,17 +217,25 @@ def test_handle_egress_ended_recording_not_found(
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_metadata, mock_notify, service
|
||||
def test_handle_egress_ended_recording_should_not_be_saved(
|
||||
mock_update_metadata, mock_notify, egress_status, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
"""Don't update status for recordings that must not be saved."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -255,12 +248,24 @@ def test_handle_egress_ended_recording_not_active(
|
||||
assert recording.status == "failed_to_stop"
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_metadata, mock_notify, service
|
||||
def test_handle_egress_ended_complete_does_not_notify_participants(
|
||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
"""Shouldn't notify participants on a successful egress.
|
||||
|
||||
EGRESS_COMPLETE is the only ended status that notifies no one: limit
|
||||
reached, aborted and failed egresses each send their own notification.
|
||||
A stopped recording is simply finalized, which is the nominal flow once
|
||||
the egress uploaded the file of a user-initiated stop.
|
||||
"""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -273,7 +278,10 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@@ -333,96 +341,163 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
mock_collector.stop.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "recording_status", "event", "expected_level"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ABORTED, "active", "aborted", logging.INFO),
|
||||
(EgressStatus.EGRESS_FAILED, "active", "failed", logging.ERROR),
|
||||
# The synchronous stop may already have persisted the terminal status,
|
||||
# and the error details exist only in the webhook payload.
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted", "aborted", logging.INFO),
|
||||
(EgressStatus.EGRESS_FAILED, "failed", "failed", logging.ERROR),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
egress_status,
|
||||
recording_status,
|
||||
event,
|
||||
expected_level,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should log the reason LiveKit reported an unsuccessful egress."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=recording_status)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.error = "could not connect to the room"
|
||||
mock_data.egress_info.error_code = 500
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
assert (
|
||||
f"Recording worker reported {event} for recording {recording.id}" in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
|
||||
worker_logs = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.name == "core.recording.services.recording_events"
|
||||
]
|
||||
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"notify_return_value, recording_status",
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
||||
def test_handle_egress_ended_does_not_log_error_on_successful_egress( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
recording_status,
|
||||
egress_status,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should notify external services and save the recording on egress completion
|
||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
|
||||
"""
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
"""Shouldn't log an egress error when LiveKit reports a successful egress."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == recording_status
|
||||
assert "Recording worker reported" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[
|
||||
EgressStatus.EGRESS_STARTING,
|
||||
EgressStatus.EGRESS_ACTIVE,
|
||||
EgressStatus.EGRESS_ENDING,
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_metadata, egress_status, service
|
||||
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
|
||||
mock_update_metadata, mock_notify, service, caplog
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
"""Should log the failure reason even when the cleanup fails afterwards."""
|
||||
|
||||
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED
|
||||
mock_data.egress_info.error = "could not connect to the room"
|
||||
mock_data.egress_info.error_code = 500
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
assert (
|
||||
f"Recording worker reported failed for recording {recording.id}" in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
|
||||
|
||||
def test_egress_status_mapping_covers_every_livekit_status():
|
||||
"""Every egress status LiveKit can report must translate to a recording event."""
|
||||
|
||||
unmapped = [
|
||||
name
|
||||
for name in EgressStatus.keys()
|
||||
if getattr(EgressStatus, name) not in EGRESS_STATUS_TO_RECORDING_EVENT
|
||||
]
|
||||
|
||||
assert not unmapped
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
("egress_status", "expected_event"),
|
||||
(
|
||||
(EgressStatus.EGRESS_STARTING, RecordingWorkerEvent.STARTING),
|
||||
(EgressStatus.EGRESS_ACTIVE, RecordingWorkerEvent.STARTED),
|
||||
(EgressStatus.EGRESS_ENDING, RecordingWorkerEvent.SAVING),
|
||||
(EgressStatus.EGRESS_COMPLETE, RecordingWorkerEvent.COMPLETED),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, RecordingWorkerEvent.LIMIT_REACHED),
|
||||
(EgressStatus.EGRESS_ABORTED, RecordingWorkerEvent.ABORTED),
|
||||
(EgressStatus.EGRESS_FAILED, RecordingWorkerEvent.FAILED),
|
||||
),
|
||||
)
|
||||
def test_to_recording_event_translates_egress_status(egress_status, expected_event):
|
||||
"""Should translate a LiveKit egress status into a recording worker event."""
|
||||
|
||||
assert to_recording_event(egress_status) == expected_event
|
||||
|
||||
|
||||
def test_to_recording_event_returns_none_on_unmapped_status(caplog):
|
||||
"""Should warn and return None when LiveKit reports an unknown status."""
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
event = to_recording_event(999)
|
||||
|
||||
assert event is None
|
||||
assert "Unmapped LiveKit egress status" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_metadata, status, service
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
|
||||
"""Shouldn't touch the room's metadata when the egress status is unknown."""
|
||||
|
||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||
"""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||
RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = 999
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core import checks
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from dockerflow.django.views import django_check_registry
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
|
||||
def test_dockerflow_endpoints_are_anonymous(client, path):
|
||||
"""Both endpoints answer without authentication."""
|
||||
response = client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
|
||||
"""The load balancer heartbeat answers 200 without touching the database."""
|
||||
with django_assert_num_queries(0):
|
||||
response = client.get("/__lbheartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
|
||||
"""The probes are served before ALLOWED_HOSTS is enforced."""
|
||||
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
|
||||
"""The heartbeat runs the database, migrations and redis checks."""
|
||||
with override_settings(DEBUG=True):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["details"] == {}
|
||||
assert payload["checks"]["check_database_connected"] == "ok"
|
||||
assert payload["checks"]["check_migrations_applied"] == "ok"
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
|
||||
"""A check reporting an error takes the heartbeat down with a 500."""
|
||||
|
||||
def failing_check(**kwargs):
|
||||
return [checks.Error("Could not connect to database", id="health.E001")]
|
||||
|
||||
with mock.patch.object(
|
||||
django_check_registry, "get_checks", return_value=[failing_check]
|
||||
):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["status"] == "error"
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_tolerates_warnings(client):
|
||||
"""A warning in the check should not affect the probes."""
|
||||
|
||||
def warning_check(**kwargs):
|
||||
return [checks.Warning("Unapplied migration", id="health.W001")]
|
||||
|
||||
with mock.patch.object(
|
||||
django_check_registry, "get_checks", return_value=[warning_check]
|
||||
):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "warning"
|
||||
@@ -157,6 +157,7 @@ def test_models_recording_is_savable_normal():
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
@@ -279,6 +280,7 @@ def test_models_recording_is_saved_false_initiated():
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
|
||||
@@ -287,6 +287,7 @@ class Base(Configuration):
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
@@ -296,7 +297,6 @@ class Base(Configuration):
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
@@ -1173,6 +1173,13 @@ class Base(Configuration):
|
||||
environ_prefix="",
|
||||
)
|
||||
},
|
||||
"dockerflow": {
|
||||
"level": values.Value(
|
||||
"WARNING",
|
||||
environ_name="LOGGING_LEVEL_DOCKERFLOW",
|
||||
environ_prefix="",
|
||||
)
|
||||
},
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
|
||||
@@ -45,7 +45,8 @@ RUN npm run build
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk del curl
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
import { Track } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import {
|
||||
closeScreenSharePopout,
|
||||
screenSharePopoutStore,
|
||||
} from '@/stores/screenSharePopout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export const StageLayout = () => {
|
||||
@@ -31,8 +35,26 @@ export const StageLayout = () => {
|
||||
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const { entry: popoutEntry } = useSnapshot(screenSharePopoutStore)
|
||||
const detachedSid = popoutEntry?.trackSid
|
||||
|
||||
const carouselTracks = tracks.filter(
|
||||
// The popped-out share stays mounted below, but out of the grid and the
|
||||
// carousel. It comes back with the other tracks when its window closes.
|
||||
const visibleTracks = detachedSid
|
||||
? tracks.filter(
|
||||
(track) =>
|
||||
!isTrackReference(track) || track.publication.trackSid !== detachedSid
|
||||
)
|
||||
: tracks
|
||||
|
||||
const detachedTrack = detachedSid
|
||||
? tracks.find(
|
||||
(track) =>
|
||||
isTrackReference(track) && track.publication.trackSid === detachedSid
|
||||
)
|
||||
: undefined
|
||||
|
||||
const carouselTracks = visibleTracks.filter(
|
||||
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||
)
|
||||
|
||||
@@ -85,11 +107,36 @@ export const StageLayout = () => {
|
||||
])
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
|
||||
const screenShareKey = screenShareTracks
|
||||
.map((track) => track.publication.trackSid)
|
||||
.join()
|
||||
|
||||
// The popped-out tile is kept mounted below, so nothing else notices when
|
||||
// the share stops. Close the window here instead.
|
||||
useEffect(() => {
|
||||
const entry = screenSharePopoutStore.entry
|
||||
if (!entry) return
|
||||
const alive = screenShareTracks.some(
|
||||
(track) => track.publication.trackSid === entry.trackSid
|
||||
)
|
||||
if (!alive) closeScreenSharePopout({ restorePin: false })
|
||||
// screenShareKey is the sid list; the array itself is new every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenShareKey])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Out of the layout, but still mounted: this subtree is what renders
|
||||
into the separate window. hidden also takes it off the a11y tree
|
||||
and out of the tab order. */}
|
||||
{detachedTrack && (
|
||||
<div hidden>
|
||||
<ParticipantTile trackRef={detachedTrack} />
|
||||
</div>
|
||||
)}
|
||||
{!pinnedTrackRef ? (
|
||||
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||
<GridLayout tracks={visibleTracks} style={{ padding: 0 }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,10 @@ export const MainNotificationToast = () => {
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
case NotificationType.TranscriptionFailed:
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
case NotificationType.TranscriptionAborted:
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Div } from '@/primitives'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
import { RecordingWaitingForTracksNotification } from './components/RecordingWaitingForTracksNotification'
|
||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||
|
||||
export const NotificationProvider = ({
|
||||
@@ -12,5 +13,6 @@ export const NotificationProvider = ({
|
||||
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
<WaitingParticipantNotification />
|
||||
<RecordingWaitingForTracksNotification />
|
||||
</Div>
|
||||
)
|
||||
|
||||
@@ -10,11 +10,15 @@ export enum NotificationType {
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||
TranscriptionFailed = 'transcriptionFailed',
|
||||
TranscriptionAborted = 'transcriptionAborted',
|
||||
TranscriptionRequested = 'transcriptionRequested',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
ScreenRecordingRequested = 'screenRecordingRequested',
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
ScreenRecordingFailed = 'screenRecordingFailed',
|
||||
ScreenRecordingAborted = 'screenRecordingAborted',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { useRecordingWaitingForTracks } from '@/features/recording/hooks/useRecordingWaitingForTracks'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
|
||||
export const RecordingWaitingForTracksNotification = () => {
|
||||
const { t } = useTranslation('notifications')
|
||||
const isTranscriptWaiting = useRecordingWaitingForTracks(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
const isScreenRecordingWaiting = useRecordingWaitingForTracks(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
|
||||
if (!isTranscriptWaiting && !isScreenRecordingWaiting) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer role="status">
|
||||
<HStack padding={14}>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '22rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{t(
|
||||
isTranscriptWaiting
|
||||
? 'transcript.waitingForTracks'
|
||||
: 'screenRecording.waitingForTracks'
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -22,12 +22,20 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
return 'transcript.limitReached'
|
||||
case NotificationType.TranscriptionFailed:
|
||||
return 'transcript.failed'
|
||||
case NotificationType.TranscriptionAborted:
|
||||
return 'transcript.aborted'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
return 'screenRecording.limitReached'
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
return 'screenRecording.failed'
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
return 'screenRecording.aborted'
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ const renderToast = (
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
case NotificationType.TranscriptionFailed:
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
case NotificationType.TranscriptionAborted:
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.TranscriptionRequested:
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Track } from 'livekit-client'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
@@ -89,6 +90,8 @@ export const ParticipantTile: (
|
||||
)
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const isRemoteScreenShare =
|
||||
isScreenShare && !trackReference.participant.isLocal
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
const participantColor = getParticipantColor(trackReference.participant)
|
||||
@@ -98,11 +101,23 @@ export const ParticipantTile: (
|
||||
})
|
||||
const participantName = name || identity || 'Unknown'
|
||||
|
||||
// tileRef: fullscreen target, and the node the focus overlay listens on.
|
||||
// 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 interactiveProps = {
|
||||
...elementProps,
|
||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||
tabIndex: 0,
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
@@ -120,8 +135,46 @@ export const ParticipantTile: (
|
||||
},
|
||||
}
|
||||
|
||||
const isVideoTrack =
|
||||
isTrackReference(trackReference) &&
|
||||
trackReference.publication.kind === 'video'
|
||||
|
||||
let trackMedia: React.ReactNode = null
|
||||
if (isVideoTrack) {
|
||||
const videoTrack = (
|
||||
<VideoTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
manageSubscription={autoManageSubscription}
|
||||
/>
|
||||
)
|
||||
// Zoom toolbar stays out of picture-in-picture: that window has its own
|
||||
// document and the fullscreen API is off. Follow-up PR can restore zoom
|
||||
// there without the dead fullscreen button.
|
||||
trackMedia =
|
||||
isRemoteScreenShare && !disableTileControls ? (
|
||||
<ScreenShareZoomableVideo
|
||||
tileRef={tileRef}
|
||||
participantName={participantName}
|
||||
trackSid={trackReference.publication.trackSid}
|
||||
windowName={`meet-screen-share-${trackReference.publication.trackSid || trackReference.participant.identity}`}
|
||||
>
|
||||
{videoTrack}
|
||||
</ScreenShareZoomableVideo>
|
||||
) : (
|
||||
videoTrack
|
||||
)
|
||||
} else if (isTrackReference(trackReference)) {
|
||||
trackMedia = (
|
||||
<AudioTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<div ref={setRefs} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
{trackReference.participant.isLocal && (
|
||||
@@ -129,23 +182,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
{children ?? (
|
||||
<>
|
||||
{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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{trackMedia}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
color={participantColor}
|
||||
@@ -164,6 +201,7 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
tileRef={tileRef}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
|
||||
+47
-25
@@ -7,38 +7,64 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { FocusButton } from './FocusButton'
|
||||
import { EffectsButton } from './EffectsButton'
|
||||
import { MuteButton } from './MuteButton'
|
||||
import { ZoomButton } from './ZoomButton'
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
type FadeOverlayProps = {
|
||||
children: ReactNode
|
||||
hasKeyboardFocus: boolean
|
||||
tileRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
||||
// The tile node still gets the mouse events, so we listen on it directly
|
||||
// rather than lifting the state up: this keeps mouse moves from re-rendering
|
||||
// the tile and the video it contains.
|
||||
const FadeOverlay = ({
|
||||
children,
|
||||
hasKeyboardFocus,
|
||||
tileRef,
|
||||
}: FadeOverlayProps) => {
|
||||
const [active, setActive] = useState(false)
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
useEffect(() => {
|
||||
const tile = tileRef.current
|
||||
if (!tile) return
|
||||
|
||||
const armIdleTimer = () => {
|
||||
clearIdleTimer()
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||
idleTimerRef.current = null
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
setActive(true)
|
||||
clearIdleTimer()
|
||||
idleTimerRef.current = window.setTimeout(
|
||||
() => setActive(false),
|
||||
MOUSE_IDLE_TIME
|
||||
)
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
clearIdleTimer()
|
||||
setActive(false)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
setActive(true)
|
||||
armIdleTimer()
|
||||
}
|
||||
tile.addEventListener('mouseenter', handleActivity)
|
||||
tile.addEventListener('mousemove', handleActivity)
|
||||
tile.addEventListener('mouseleave', handleLeave)
|
||||
|
||||
useEffect(() => clearIdleTimer, [])
|
||||
return () => {
|
||||
clearIdleTimer()
|
||||
tile.removeEventListener('mouseenter', handleActivity)
|
||||
tile.removeEventListener('mousemove', handleActivity)
|
||||
tile.removeEventListener('mouseleave', handleLeave)
|
||||
}
|
||||
}, [tileRef])
|
||||
|
||||
const isVisible = hasKeyboardFocus || active
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -50,15 +76,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
data-visible={isVisible || undefined}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={handleActivity}
|
||||
onMouseMove={handleActivity}
|
||||
onMouseLeave={() => {
|
||||
clearIdleTimer()
|
||||
setActive(false)
|
||||
}}
|
||||
>
|
||||
{isVisible && children}
|
||||
</div>
|
||||
@@ -67,9 +88,11 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
tileRef,
|
||||
hasKeyboardFocus,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
tileRef: React.RefObject<HTMLDivElement | null>
|
||||
hasKeyboardFocus: boolean
|
||||
}) => {
|
||||
const participant = trackRef.participant
|
||||
@@ -78,7 +101,7 @@ export const ParticipantTileFocus = ({
|
||||
const canMute = useCanMute(participant)
|
||||
|
||||
return (
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
@@ -87,6 +110,7 @@ export const ParticipantTileFocus = ({
|
||||
display: 'flex',
|
||||
opacity: 0.6,
|
||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||
pointerEvents: 'auto',
|
||||
_hover: {
|
||||
opacity: 0.95,
|
||||
},
|
||||
@@ -94,7 +118,7 @@ export const ParticipantTileFocus = ({
|
||||
>
|
||||
<HStack gap={0.5} padding={0.5}>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
{!isScreenShare && (
|
||||
<>
|
||||
{isLocal ? (
|
||||
<EffectsButton />
|
||||
@@ -102,8 +126,6 @@ export const ParticipantTileFocus = ({
|
||||
canMute && <MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTracks } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { useRecordingStatuses } from './useRecordingStatuses'
|
||||
|
||||
const STARTING_NOTIFICATION_DELAY = 15_000
|
||||
|
||||
export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
|
||||
const { isStarting } = useRecordingStatuses(mode)
|
||||
const tracks = useTracks(
|
||||
mode === RecordingMode.Transcript ? [Track.Source.Microphone] : undefined,
|
||||
{ onlySubscribed: false }
|
||||
)
|
||||
const [delayElapsed, setDelayElapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDelayElapsed(false)
|
||||
if (!isStarting) return
|
||||
|
||||
const timeout = window.setTimeout(
|
||||
() => setDelayElapsed(true),
|
||||
STARTING_NOTIFICATION_DELAY
|
||||
)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [isStarting, mode])
|
||||
|
||||
return isStarting && delayElapsed && tracks.length === 0
|
||||
}
|
||||
@@ -9,8 +9,9 @@ export enum RecordingStatus {
|
||||
Stopped = 'stopped',
|
||||
Saved = 'saved',
|
||||
Aborted = 'aborted',
|
||||
FailedToStart = 'failedToStart',
|
||||
FailedToStop = 'failedToStop',
|
||||
Failed = 'failed',
|
||||
FailedToStart = 'failed_to_start',
|
||||
FailedToStop = 'failed_to_stop',
|
||||
NotificationSucceed = 'notification_succeeded',
|
||||
ExternalProcessSuccessful = 'external_process_successful',
|
||||
ExternalProcessFailed = 'external_process_failed',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
const getOwnerDocument = (el: Element | null) => el?.ownerDocument ?? document
|
||||
|
||||
// Keeps the fullscreen state here rather than on the toolbar, so entering or
|
||||
// leaving fullscreen does not re-render the zoom controls.
|
||||
export const ScreenShareFullscreenButton = memo(
|
||||
({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [isFullscreenAvailable, setIsFullscreenAvailable] = useState(
|
||||
() => document.fullscreenEnabled
|
||||
)
|
||||
// Tracks whether this tile's container triggered fullscreen (vs another share's).
|
||||
const wasThisTileFullscreen = useRef(false)
|
||||
|
||||
// Covers Esc and browser UI exits, not just this button.
|
||||
// Listens on the element's own document, so it still works in the popup.
|
||||
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
||||
useEffect(() => {
|
||||
const doc = getOwnerDocument(containerRef.current)
|
||||
setIsFullscreenAvailable(!!doc.fullscreenEnabled)
|
||||
|
||||
const onChange = () => {
|
||||
const isThisTileFullscreen =
|
||||
doc.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')
|
||||
}
|
||||
}
|
||||
doc.addEventListener('fullscreenchange', onChange)
|
||||
return () => doc.removeEventListener('fullscreenchange', onChange)
|
||||
}, [announce, t, containerRef])
|
||||
|
||||
const toggleFullScreen = useCallback(async () => {
|
||||
const doc = getOwnerDocument(containerRef.current)
|
||||
try {
|
||||
if (doc.fullscreenElement === containerRef.current) {
|
||||
await doc.exitFullscreen()
|
||||
} else {
|
||||
// Tile / pop-out chrome so zoom controls stay visible in fullscreen.
|
||||
await containerRef.current?.requestFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling fullscreen:', error)
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
if (!isFullscreenAvailable) return null
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ScreenShareFullscreenButton.displayName = 'ScreenShareFullscreenButton'
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { UNSAFE_PortalProvider } from '@react-aria/overlays'
|
||||
import { ScreenReaderAnnouncer } from '@/primitives'
|
||||
import { CrossDocumentOverlaysContext } from '@/primitives/CrossDocumentOverlaysContext'
|
||||
|
||||
/**
|
||||
* Render in the popup. Tooltips have to live there too, or they show up
|
||||
* in the meeting window instead.
|
||||
*/
|
||||
export const ScreenSharePopoutPortal = ({
|
||||
container,
|
||||
children,
|
||||
}: {
|
||||
container: HTMLElement
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
if (!container.isConnected) return null
|
||||
|
||||
return createPortal(
|
||||
<UNSAFE_PortalProvider getContainer={() => container}>
|
||||
<CrossDocumentOverlaysContext.Provider value={true}>
|
||||
{/* The meeting live region sits in the other document, which a screen
|
||||
reader stops reading once the focus is here. */}
|
||||
<ScreenReaderAnnouncer />
|
||||
{children}
|
||||
</CrossDocumentOverlaysContext.Provider>
|
||||
</UNSAFE_PortalProvider>,
|
||||
container
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button } from '@/primitives'
|
||||
import {
|
||||
RiFullscreenExitLine,
|
||||
RiArrowGoBackLine,
|
||||
RiShareBoxLine,
|
||||
RiZoomInLine,
|
||||
RiZoomOutLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Toolbar } from 'react-aria-components'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { srOnly } from '@/styles/a11y'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { ScreenShareFullscreenButton } from './ScreenShareFullscreenButton'
|
||||
|
||||
interface ScreenShareZoomControlsProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
isZoomed: boolean
|
||||
zoomPercentage: number
|
||||
canZoomIn: boolean
|
||||
canZoomOut: boolean
|
||||
isPoppedOut: boolean
|
||||
popoutButtonRef: React.Ref<HTMLButtonElement>
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onResetZoom: () => void
|
||||
onTogglePopout: () => void
|
||||
}
|
||||
|
||||
export const ScreenShareZoomControls = ({
|
||||
containerRef,
|
||||
isZoomed,
|
||||
zoomPercentage,
|
||||
canZoomIn,
|
||||
canZoomOut,
|
||||
isPoppedOut,
|
||||
popoutButtonRef,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetZoom,
|
||||
onTogglePopout,
|
||||
}: ScreenShareZoomControlsProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const hadFocusInCollapsibleRef = useRef(false)
|
||||
|
||||
// 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 wheelShortcut = t(isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '12px',
|
||||
right: '12px',
|
||||
zIndex: 2,
|
||||
pointerEvents: 'auto',
|
||||
})}
|
||||
>
|
||||
<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: wheelShortcut,
|
||||
})}
|
||||
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: wheelShortcut })}
|
||||
aria-label={t('zoomIn')}
|
||||
isDisabled={!canZoomIn}
|
||||
onPress={onZoomIn}
|
||||
>
|
||||
<RiZoomInLine size={20} />
|
||||
</Button>
|
||||
{/* Desktop only — popups on mobile are usually blocked. */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
ref={popoutButtonRef}
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={
|
||||
isPoppedOut ? t('closeSeparateWindow') : t('openInSeparateWindow')
|
||||
}
|
||||
aria-label={
|
||||
isPoppedOut ? t('closeSeparateWindow') : t('openInSeparateWindow')
|
||||
}
|
||||
onPress={onTogglePopout}
|
||||
>
|
||||
{isPoppedOut ? (
|
||||
<RiArrowGoBackLine size={20} />
|
||||
) : (
|
||||
<RiShareBoxLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<ScreenShareFullscreenButton containerRef={containerRef} />
|
||||
</Toolbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
|
||||
import { useScreenSharePopout } from '../hooks/useScreenSharePopout'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
|
||||
import { ScreenSharePopoutPortal } from './ScreenSharePopoutPortal'
|
||||
import {
|
||||
saveScreenShareZoom,
|
||||
takePopoutButtonFocus,
|
||||
takeScreenShareZoom,
|
||||
} from '@/stores/screenSharePopout'
|
||||
|
||||
interface ScreenShareZoomableVideoProps {
|
||||
tileRef: React.RefObject<HTMLDivElement | null>
|
||||
participantName: string
|
||||
trackSid: string
|
||||
windowName: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const popoutChromeClassName = css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
outline: 'none',
|
||||
_focusVisible: {
|
||||
outline: '2px solid',
|
||||
outlineColor: 'primary.500',
|
||||
outlineOffset: '-2px',
|
||||
},
|
||||
'& .lk-participant-media-video': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
})
|
||||
|
||||
export const ScreenShareZoomableVideo = ({
|
||||
tileRef,
|
||||
participantName,
|
||||
trackSid,
|
||||
windowName,
|
||||
children,
|
||||
}: ScreenShareZoomableVideoProps) => {
|
||||
const zoom = useScreenShareZoom()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const popoutChromeRef = useRef<HTMLDivElement>(null)
|
||||
const popoutButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const wasPoppedOut = useRef(false)
|
||||
|
||||
const getVideoElement = useCallback(
|
||||
() => zoom.transformElRef.current?.querySelector('video') ?? null,
|
||||
[zoom.transformElRef]
|
||||
)
|
||||
|
||||
const popout = useScreenSharePopout({
|
||||
trackSid,
|
||||
windowName,
|
||||
title: t('separateWindowTitle', { name: participantName }),
|
||||
getVideoElement,
|
||||
})
|
||||
|
||||
// Moving the video in or out of the window remounts this tile, because the
|
||||
// stage pin changes. Stash the zoom so the new instance picks it up.
|
||||
const { capture, resync } = zoom
|
||||
useLayoutEffect(() => {
|
||||
return () => saveScreenShareZoom(trackSid, capture())
|
||||
}, [trackSid, capture])
|
||||
|
||||
// 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])
|
||||
|
||||
// Keys on the tile, or on the popup once the video is over there.
|
||||
useEffect(() => {
|
||||
const el = popout.isOpen ? popoutChromeRef.current : tileRef.current
|
||||
if (!el) return
|
||||
el.addEventListener('keydown', zoom.handleKeyDown)
|
||||
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
||||
}, [popout.isOpen, tileRef, zoom.handleKeyDown])
|
||||
|
||||
// Native wheel listener with { passive: false } so preventDefault works.
|
||||
// Re-attach when the video moves to the other window.
|
||||
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, popout.isOpen])
|
||||
|
||||
// Open: focus the popup. Close: focus the button again (the toolbar remounts).
|
||||
useLayoutEffect(() => {
|
||||
resync(takeScreenShareZoom(trackSid) ?? undefined)
|
||||
if (popout.isOpen) {
|
||||
wasPoppedOut.current = true
|
||||
popoutChromeRef.current?.focus()
|
||||
return
|
||||
}
|
||||
if (!wasPoppedOut.current && !takePopoutButtonFocus(trackSid)) return
|
||||
wasPoppedOut.current = false
|
||||
popoutButtonRef.current?.focus()
|
||||
}, [popout.isOpen, resync, trackSid])
|
||||
|
||||
// LiveKit unsubscribes tiles it believes are off-screen. Its observer
|
||||
// cannot measure an element living in another window and reads it as
|
||||
// hidden, which would drop the track a few seconds after opening.
|
||||
const video =
|
||||
popout.isOpen && isValidElement<{ manageSubscription?: boolean }>(children)
|
||||
? cloneElement(children, { manageSubscription: false })
|
||||
: children
|
||||
|
||||
const media = (
|
||||
<>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{video}
|
||||
</div>
|
||||
</div>
|
||||
<ScreenShareZoomControls
|
||||
containerRef={popout.isOpen ? popoutChromeRef : tileRef}
|
||||
isZoomed={zoom.isZoomed}
|
||||
zoomPercentage={zoom.zoomPercentage}
|
||||
canZoomIn={zoom.canZoomIn}
|
||||
canZoomOut={zoom.canZoomOut}
|
||||
isPoppedOut={popout.isOpen}
|
||||
popoutButtonRef={popoutButtonRef}
|
||||
onZoomIn={zoom.zoomIn}
|
||||
onZoomOut={zoom.zoomOut}
|
||||
onResetZoom={zoom.resetZoom}
|
||||
onTogglePopout={popout.toggle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
// Video in the popup. The meeting layout does not keep a tile for it.
|
||||
if (popout.isOpen && popout.container) {
|
||||
return (
|
||||
<ScreenSharePopoutPortal container={popout.container}>
|
||||
<div
|
||||
ref={popoutChromeRef}
|
||||
role="group"
|
||||
// Focusable on purpose: it carries the zoom key handler, and the
|
||||
// popup body is an ancestor, so keys would never bubble to it.
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
aria-label={t('separateWindowLabel', { name: participantName })}
|
||||
className={popoutChromeClassName}
|
||||
>
|
||||
{media}
|
||||
</div>
|
||||
</ScreenSharePopoutPortal>
|
||||
)
|
||||
}
|
||||
|
||||
return media
|
||||
}
|
||||
+27
-18
@@ -1,17 +1,16 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
RoomEvent,
|
||||
Track,
|
||||
VideoQuality,
|
||||
} from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
|
||||
/**
|
||||
* Sets initial video quality for new participants as they join.
|
||||
* Applies the saved reception quality to every remote camera.
|
||||
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||
*/
|
||||
export const VideoResolutionSubscription = () => {
|
||||
@@ -19,30 +18,40 @@ export const VideoResolutionSubscription = () => {
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
if (!room || videoSubscribeQuality === undefined) return
|
||||
|
||||
const handleTrackPublished = (
|
||||
publication: RemoteTrackPublication,
|
||||
_participant: RemoteParticipant
|
||||
) => {
|
||||
// By default, the maximum quality is set to high
|
||||
const applyQuality = (publication: RemoteTrackPublication) => {
|
||||
if (
|
||||
videoSubscribeQuality === undefined ||
|
||||
videoSubscribeQuality === VideoQuality.HIGH
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
publication.kind === Track.Kind.Video &&
|
||||
publication.source !== Track.Source.ScreenShare
|
||||
publication.kind !== Track.Kind.Video ||
|
||||
publication.source === Track.Source.ScreenShare ||
|
||||
publication.videoQuality === videoSubscribeQuality
|
||||
) {
|
||||
publication.setVideoQuality(videoSubscribeQuality)
|
||||
return
|
||||
}
|
||||
publication.setVideoQuality(videoSubscribeQuality)
|
||||
}
|
||||
|
||||
// Cameras we are already receiving: those published before this effect ran,
|
||||
// and all of them again whenever the preference changes mid-call.
|
||||
room.remoteParticipants.forEach((participant) =>
|
||||
participant.videoTrackPublications.forEach(applyQuality)
|
||||
)
|
||||
|
||||
const handleTrackPublished = (publication: RemoteTrackPublication) =>
|
||||
applyQuality(publication)
|
||||
|
||||
// TrackPublished is not raised for cameras that were already sending when we
|
||||
// joined, but it is the earliest point for the ones that start after us.
|
||||
const handleTrackSubscribed = (
|
||||
_track: RemoteTrack,
|
||||
publication: RemoteTrackPublication
|
||||
) => applyQuality(publication)
|
||||
|
||||
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
room.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
|
||||
}
|
||||
}, [room, videoSubscribeQuality])
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import {
|
||||
closeScreenSharePopout,
|
||||
openScreenSharePopout,
|
||||
screenSharePopoutStore,
|
||||
} from '@/stores/screenSharePopout'
|
||||
import {
|
||||
getAuxiliaryWindowFeatures,
|
||||
getAuxiliaryWindowSize,
|
||||
initializeAuxiliaryWindow,
|
||||
} from '@/utils/auxiliaryWindow'
|
||||
|
||||
type UseScreenSharePopoutOptions = {
|
||||
trackSid: string
|
||||
windowName: string
|
||||
title: string
|
||||
getVideoElement?: () => HTMLVideoElement | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the screen share in another window. Closing it does not stop the
|
||||
* share: the video just comes back into the meeting.
|
||||
*
|
||||
* The window lives in a store, not in this hook. Opening it drops the stage
|
||||
* pin, so the tile moves from the focus layout into the grid and this
|
||||
* component remounts. The store is what keeps the window open across that.
|
||||
*
|
||||
* A real popup, not the meeting PiP, that one is already taken, and a
|
||||
* popup can be as large as another screen.
|
||||
*/
|
||||
export const useScreenSharePopout = ({
|
||||
trackSid,
|
||||
windowName,
|
||||
title,
|
||||
getVideoElement,
|
||||
}: UseScreenSharePopoutOptions) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const { entry } = useSnapshot(screenSharePopoutStore)
|
||||
const isOpen = entry?.trackSid === trackSid
|
||||
|
||||
// Brings the video back into the meeting, from the toolbar button as well as
|
||||
// from the window's own close button. Safe to call after this hook's
|
||||
// component has unmounted: the listener sits on the popup, not on the tile.
|
||||
const release = useCallback(() => {
|
||||
if (screenSharePopoutStore.entry?.trackSid !== trackSid) return
|
||||
closeScreenSharePopout({ restorePin: true })
|
||||
announce(t('separateWindowClosed'), 'assertive')
|
||||
}, [announce, t, trackSid])
|
||||
|
||||
const open = useCallback(() => {
|
||||
if (screenSharePopoutStore.entry) return
|
||||
|
||||
const { width, height } = getAuxiliaryWindowSize(getVideoElement?.())
|
||||
// Open right away: waiting first (fullscreen, etc.) lets the browser
|
||||
// block the popup.
|
||||
const next = window.open(
|
||||
'',
|
||||
windowName,
|
||||
getAuxiliaryWindowFeatures(width, height)
|
||||
)
|
||||
|
||||
if (!next) {
|
||||
announce(t('separateWindowBlocked'), 'assertive')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const container = initializeAuxiliaryWindow(next, { title })
|
||||
|
||||
openScreenSharePopout({
|
||||
trackSid,
|
||||
popup: next,
|
||||
container,
|
||||
// The window X does not go through close() — still bring the video back.
|
||||
onPopupClosed: release,
|
||||
})
|
||||
next.focus()
|
||||
announce(t('separateWindowOpened'), 'assertive')
|
||||
// Drop meeting fullscreen: the stage this share was filling goes away.
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
reportError('generic_failure', error, {
|
||||
context: 'screen_share_popout_init',
|
||||
})
|
||||
next.close()
|
||||
}
|
||||
}, [announce, getVideoElement, release, t, title, trackSid, windowName])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (screenSharePopoutStore.entry?.trackSid === trackSid) release()
|
||||
else open()
|
||||
}, [open, release, trackSid])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
// The snapshot deep-freezes the element. The portal needs the real node,
|
||||
// which `ref()` kept out of the proxy.
|
||||
container: isOpen
|
||||
? (screenSharePopoutStore.entry?.container ?? null)
|
||||
: null,
|
||||
open,
|
||||
close: release,
|
||||
toggle,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useMove } from 'react-aria'
|
||||
import type { MoveMoveEvent } from '@react-types/shared'
|
||||
import {
|
||||
FULL_PICTURE_RATIO,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM,
|
||||
PAN_STEP,
|
||||
WHEEL_ZOOM_SPEED,
|
||||
ZOOM_STEP,
|
||||
type PanOffset,
|
||||
clampPan,
|
||||
clampZoom,
|
||||
getCursorFromZoomState,
|
||||
getCursorPercentsFromWheelEvent,
|
||||
getPanDeltaPercentsFromMove,
|
||||
getPictureRatio,
|
||||
getWheelPanOffset,
|
||||
getZoomTransform,
|
||||
} from '../utils/screenShareZoom'
|
||||
|
||||
export type ZoomState = { zoom: number; pan: PanOffset }
|
||||
|
||||
/**
|
||||
* 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 dragging and panning never re-render.
|
||||
* React state only tracks what the toolbar displays, and only updates when
|
||||
* the zoom level actually changes.
|
||||
*
|
||||
* Drag/touch panning is handled by react-aria's useMove (moveProps).
|
||||
* The wheel listener (non-passive) zooms on Ctrl/Cmd+scroll and pans on a
|
||||
* two-finger trackpad scroll once zoomed.
|
||||
* 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)
|
||||
|
||||
// Mirrors zoomRef for the toolbar. Panning never publishes: the toolbar
|
||||
// shows the zoom level, not the position.
|
||||
const [zoomLevel, setZoomLevel] = useState(MIN_ZOOM)
|
||||
|
||||
const syncToolbar = useCallback(() => setZoomLevel(zoomRef.current), [])
|
||||
|
||||
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
|
||||
)
|
||||
}, [])
|
||||
|
||||
// After the video moves to the other window, write the current zoom back
|
||||
// on the new nodes (otherwise it looks like 100 % until the next scroll).
|
||||
// Pass a state to adopt one captured before a remount; the pan is clamped
|
||||
// against the new container either way.
|
||||
const resync = useCallback(
|
||||
(state?: ZoomState) => {
|
||||
if (state) {
|
||||
zoomRef.current = state.zoom
|
||||
panRef.current = state.pan
|
||||
}
|
||||
panRef.current = clampPan(
|
||||
panRef.current,
|
||||
zoomRef.current,
|
||||
readPictureRatio()
|
||||
)
|
||||
applyTransform()
|
||||
applyCursor()
|
||||
syncToolbar()
|
||||
},
|
||||
[applyCursor, applyTransform, syncToolbar, readPictureRatio]
|
||||
)
|
||||
|
||||
const setZoom = useCallback(
|
||||
(next: number) => {
|
||||
zoomRef.current = next
|
||||
panRef.current =
|
||||
next <= MIN_ZOOM
|
||||
? { x: 0, y: 0 }
|
||||
: clampPan(panRef.current, next, readPictureRatio())
|
||||
applyTransform()
|
||||
applyCursor()
|
||||
syncToolbar()
|
||||
},
|
||||
[applyTransform, applyCursor, syncToolbar, readPictureRatio]
|
||||
)
|
||||
|
||||
const zoomIn = useCallback(
|
||||
() => setZoom(clampZoom(zoomRef.current + ZOOM_STEP)),
|
||||
[setZoom]
|
||||
)
|
||||
|
||||
const zoomOut = useCallback(
|
||||
() => setZoom(clampZoom(zoomRef.current - ZOOM_STEP)),
|
||||
[setZoom]
|
||||
)
|
||||
|
||||
const resetZoom = useCallback(() => setZoom(MIN_ZOOM), [setZoom])
|
||||
|
||||
// Cancels the scale() that multiplies translate(), so the picture follows
|
||||
// the pointer 1:1 and keyboard steps keep the same visual size.
|
||||
const panBy = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
const zoom = zoomRef.current
|
||||
panRef.current = clampPan(
|
||||
{
|
||||
x: panRef.current.x + dx / zoom,
|
||||
y: panRef.current.y + dy / zoom,
|
||||
},
|
||||
zoom,
|
||||
readPictureRatio()
|
||||
)
|
||||
applyTransform()
|
||||
},
|
||||
[applyTransform, readPictureRatio]
|
||||
)
|
||||
|
||||
// Must be attached with { passive: false } so preventDefault() blocks
|
||||
// the browser's native Ctrl+scroll page zoom. Trackpad pinch arrives
|
||||
// here as a wheel event with ctrl/cmd already set.
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
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()
|
||||
syncToolbar()
|
||||
return
|
||||
}
|
||||
|
||||
// Two-finger trackpad scroll: pan only once zoomed, otherwise leave
|
||||
// the event alone so the page can still scroll.
|
||||
if (zoomRef.current <= MIN_ZOOM) return
|
||||
|
||||
const el = surfaceElRef.current
|
||||
if (!el) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
||||
-e.deltaX,
|
||||
-e.deltaY,
|
||||
el
|
||||
)
|
||||
panBy(deltaXPercent, deltaYPercent)
|
||||
},
|
||||
[applyTransform, applyCursor, syncToolbar, readPictureRatio, panBy]
|
||||
)
|
||||
|
||||
// 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()
|
||||
},
|
||||
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
|
||||
)
|
||||
panBy(deltaXPercent, deltaYPercent)
|
||||
},
|
||||
onMoveEnd() {
|
||||
draggingRef.current = false
|
||||
applyTransform()
|
||||
applyCursor()
|
||||
},
|
||||
})
|
||||
|
||||
// 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]
|
||||
)
|
||||
|
||||
// The tile remounts when the layout switches. Callers stash this across
|
||||
// that remount so the popup keeps the zoom the user already had.
|
||||
const capture = useCallback(
|
||||
(): ZoomState => ({ zoom: zoomRef.current, pan: { ...panRef.current } }),
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
zoomPercentage: Math.round(zoomLevel * 100),
|
||||
isZoomed: zoomLevel > MIN_ZOOM,
|
||||
canZoomIn: zoomLevel < MAX_ZOOM,
|
||||
canZoomOut: zoomLevel > MIN_ZOOM,
|
||||
transformElRef,
|
||||
surfaceElRef,
|
||||
moveProps,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
resync,
|
||||
capture,
|
||||
handleWheel,
|
||||
handleKeyDown,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isWeb } from '@livekit/components-core'
|
||||
import { MediaDeviceFailure, Track } from 'livekit-client'
|
||||
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import {
|
||||
ConnectionStateToast,
|
||||
RoomAudioRenderer,
|
||||
@@ -32,6 +32,7 @@ import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
||||
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
|
||||
import { closeScreenSharePopout } from '@/stores/screenSharePopout'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -78,6 +79,12 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
|
||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||
|
||||
// Picture-in-picture replaces the stage, which is what renders a popped-out
|
||||
// share. Bring it back rather than leave an empty window behind.
|
||||
useEffect(() => {
|
||||
if (isPictureInPictureOpen) closeScreenSharePopout({ restorePin: true })
|
||||
}, [isPictureInPictureOpen])
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
|
||||
const handleDeviceError = ({
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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 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 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,
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const EMPTY_PROPS = {}
|
||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||
const room = useRoomContext()
|
||||
const { localParticipant, remoteParticipants } = room
|
||||
const { localParticipant } = room
|
||||
|
||||
const {
|
||||
videoDeviceId,
|
||||
@@ -88,22 +88,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates video quality for all existing remote video tracks when user preference changes.
|
||||
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
|
||||
* so this function applies the selected quality to all existing remote video tracks.
|
||||
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
|
||||
*/
|
||||
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
|
||||
remoteParticipants.forEach((participant) => {
|
||||
participant.videoTrackPublications.forEach((publication) => {
|
||||
if (publication.videoQuality !== selectedQuality) {
|
||||
publication.setVideoQuality(selectedQuality)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let videoTrack: LocalVideoTrack | null = null
|
||||
|
||||
@@ -231,9 +215,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
selectedKey={videoSubscribeQuality?.toString()}
|
||||
onSelectionChange={(key) => {
|
||||
if (key == undefined) return
|
||||
const selectedQuality = Number(String(key))
|
||||
saveVideoSubscribeQuality(selectedQuality)
|
||||
updateExistingRemoteVideoQuality(selectedQuality)
|
||||
saveVideoSubscribeQuality(Number(String(key)))
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
|
||||
@@ -16,6 +16,10 @@ export type ShortcutId =
|
||||
| 'recording'
|
||||
| 'reaction'
|
||||
| 'fullscreen'
|
||||
| 'zoom-in'
|
||||
| 'zoom-out'
|
||||
| 'zoom-reset'
|
||||
| 'zoom-pan'
|
||||
|
||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||
shortcutCatalog.find((item) => item.id === id)
|
||||
@@ -24,7 +28,7 @@ export type ShortcutDescriptor = {
|
||||
id: ShortcutId
|
||||
category: ShortcutCategory
|
||||
shortcut?: Shortcut
|
||||
kind?: 'press' | 'longPress'
|
||||
kind?: 'press' | 'longPress' | 'arrows'
|
||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||
description?: string
|
||||
}
|
||||
@@ -86,4 +90,27 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
category: 'interaction',
|
||||
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,6 +25,7 @@ export const formatShortcutLabelForSR = (
|
||||
shiftLabel,
|
||||
plusLabel,
|
||||
noShortcutLabel,
|
||||
keyLabels,
|
||||
}: {
|
||||
controlLabel: string
|
||||
commandLabel: string
|
||||
@@ -33,10 +34,12 @@ export const formatShortcutLabelForSR = (
|
||||
shiftLabel: string
|
||||
plusLabel: string
|
||||
noShortcutLabel: string
|
||||
// Spelled-out names for keys screen readers may skip or mispronounce.
|
||||
keyLabels?: Record<string, string>
|
||||
}
|
||||
) => {
|
||||
if (!shortcut) return noShortcutLabel
|
||||
const key = shortcut.key?.toUpperCase()
|
||||
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
|
||||
if (!key) return noShortcutLabel
|
||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||
|
||||
@@ -12,6 +12,9 @@ export const useShortcutFormatting = () => {
|
||||
|
||||
const formatVisual = useCallback(
|
||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||
if (kind === 'arrows') {
|
||||
return t('shortcutsPanel.visual.arrows')
|
||||
}
|
||||
if (code && kind === 'longPress') {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
||||
@@ -23,6 +26,9 @@ export const useShortcutFormatting = () => {
|
||||
|
||||
const formatForSR = useCallback(
|
||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||
if (kind === 'arrows') {
|
||||
return t('shortcutsPanel.sr.arrows')
|
||||
}
|
||||
if (code && kind === 'longPress') {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
||||
@@ -35,6 +41,10 @@ export const useShortcutFormatting = () => {
|
||||
shiftLabel: t('shortcutsPanel.sr.shift'),
|
||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||
keyLabels: {
|
||||
'+': t('shortcutsPanel.sr.plusKey'),
|
||||
'-': t('shortcutsPanel.sr.minusKey'),
|
||||
},
|
||||
})
|
||||
},
|
||||
[t]
|
||||
|
||||
@@ -33,15 +33,21 @@
|
||||
"accept": "Hereinlassen"
|
||||
},
|
||||
"transcript": {
|
||||
"waitingForTracks": "Die Transkription wartet auf ein Mikrofon. Schalten Sie Ihr Mikrofon ein, damit die Transkription starten kann.",
|
||||
"started": "{{name}} hat die Meeting-Transkription gestartet.",
|
||||
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
|
||||
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"failed": "Die Transkription wurde unerwartet beendet und konnte nicht gespeichert werden.",
|
||||
"aborted": "Die Transkription konnte nicht gestartet werden.",
|
||||
"requested": "{{name}} möchte die Meeting-Transkription starten."
|
||||
},
|
||||
"screenRecording": {
|
||||
"waitingForTracks": "Die Aufnahme wartet auf Audio oder Video. Schalten Sie Ihr Mikrofon oder Ihre Kamera ein oder teilen Sie Ihren Bildschirm, damit die Aufnahme starten kann.",
|
||||
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
|
||||
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
|
||||
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"failed": "Die Aufzeichnung wurde unerwartet beendet und konnte nicht gespeichert werden.",
|
||||
"aborted": "Die Aufzeichnung konnte nicht gestartet werden.",
|
||||
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -756,6 +756,31 @@
|
||||
"muteParticipant": "{{name}} stummschalten",
|
||||
"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": "⌘+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",
|
||||
"openInSeparateWindow": "In eigenem Fenster öffnen",
|
||||
"closeSeparateWindow": "Bildschirmfreigabe zurück in die Besprechung holen",
|
||||
"separateWindowOpened": "Bildschirmfreigabe in einem eigenen Fenster geöffnet",
|
||||
"separateWindowClosed": "Bildschirmfreigabe zurück in die Besprechung geholt",
|
||||
"separateWindowBlocked": "Der Browser hat das eigene Fenster blockiert. Erlauben Sie Pop-ups für diese Website und versuchen Sie es erneut.",
|
||||
"separateWindowTitle": "Bildschirmfreigabe von {{name}}",
|
||||
"separateWindowLabel": "Bildschirmfreigabe von {{name}} in einem eigenen Fenster"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Tastenkürzel",
|
||||
"categories": {
|
||||
@@ -775,7 +800,11 @@
|
||||
"raise-hand": "Hand heben oder senken",
|
||||
"toggle-chat": "Chat 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": {
|
||||
"control": "Steuerung",
|
||||
@@ -785,10 +814,14 @@
|
||||
"shift": "Umschalt",
|
||||
"plus": "plus",
|
||||
"hold": "Halte {{key}} gedrückt",
|
||||
"arrows": "Pfeiltasten",
|
||||
"plusKey": "Plus-Taste",
|
||||
"minusKey": "Minus-Taste",
|
||||
"noShortcut": "Kein Tastenkürzel"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Halte {{key}} gedrückt"
|
||||
"hold": "Halte {{key}} gedrückt",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
@@ -33,15 +33,21 @@
|
||||
"accept": "Accept"
|
||||
},
|
||||
"transcript": {
|
||||
"waitingForTracks": "Transcription is waiting for a microphone. Turn on your microphone so the transcription can start.",
|
||||
"started": "{{name}} started the meeting transcription.",
|
||||
"stopped": "{{name}} stopped the meeting transcription.",
|
||||
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
|
||||
"failed": "The transcription stopped unexpectedly and could not be saved.",
|
||||
"aborted": "The transcription could not be started.",
|
||||
"requested": "{{name}} wants to start the meeting transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"waitingForTracks": "Recording is waiting for audio or video. Turn on your microphone or camera, or share your screen, so the recording can start.",
|
||||
"started": "{{name}} started the meeting recording.",
|
||||
"stopped": "{{name}} stopped the meeting recording.",
|
||||
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
|
||||
"failed": "The recording stopped unexpectedly and could not be saved.",
|
||||
"aborted": "The recording could not be started.",
|
||||
"requested": "{{name}} wants to start the meeting recording."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -756,6 +756,31 @@
|
||||
"muteParticipant": "Mute {{name}}",
|
||||
"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": "⌘+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",
|
||||
"openInSeparateWindow": "Open in separate window",
|
||||
"closeSeparateWindow": "Return screen share to meeting",
|
||||
"separateWindowOpened": "Screen share opened in a separate window",
|
||||
"separateWindowClosed": "Screen share returned to the meeting",
|
||||
"separateWindowBlocked": "The browser blocked the separate window. Allow pop-ups for this site and try again.",
|
||||
"separateWindowTitle": "{{name}}'s screen share",
|
||||
"separateWindowLabel": "{{name}}'s screen share in a separate window"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"categories": {
|
||||
@@ -775,7 +800,11 @@
|
||||
"raise-hand": "Raise or lower hand",
|
||||
"toggle-chat": "Toggle chat",
|
||||
"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": {
|
||||
"control": "Control",
|
||||
@@ -785,10 +814,14 @@
|
||||
"shift": "Shift",
|
||||
"plus": "plus",
|
||||
"hold": "Hold {{key}}",
|
||||
"arrows": "Arrow keys",
|
||||
"plusKey": "Plus key",
|
||||
"minusKey": "Minus key",
|
||||
"noShortcut": "No shortcut"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Hold {{key}}"
|
||||
"hold": "Hold {{key}}",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
@@ -33,15 +33,21 @@
|
||||
"accept": "Aceptar"
|
||||
},
|
||||
"transcript": {
|
||||
"waitingForTracks": "La transcripción está esperando un micrófono. Activa el micrófono para que la transcripción pueda comenzar.",
|
||||
"started": "{{name}} ha iniciado la transcripción de la reunión.",
|
||||
"stopped": "{{name}} ha detenido la transcripción de la reunión.",
|
||||
"limitReached": "La transcripción ha superado la duración máxima permitida, se va a guardar automáticamente.",
|
||||
"failed": "La transcripción se ha interrumpido de forma inesperada y no se ha podido guardar.",
|
||||
"aborted": "No se ha podido iniciar la transcripción.",
|
||||
"requested": "{{name}} ha solicitado iniciar la transcripción."
|
||||
},
|
||||
"screenRecording": {
|
||||
"waitingForTracks": "La grabación está esperando audio o vídeo. Activa el micrófono o la cámara, o comparte la pantalla, para que la grabación pueda comenzar.",
|
||||
"started": "{{name}} ha iniciado la grabación de la reunión.",
|
||||
"stopped": "{{name}} ha detenido la grabación de la reunión.",
|
||||
"limitReached": "La grabación ha superado la duración máxima permitida, se va a guardar automáticamente.",
|
||||
"failed": "La grabación se ha interrumpido de forma inesperada y no se ha podido guardar.",
|
||||
"aborted": "No se ha podido iniciar la grabación.",
|
||||
"requested": "{{name}} ha solicitado iniciar la grabación."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -755,6 +755,31 @@
|
||||
"muteParticipant": "Silenciar el micrófono de {{name}}",
|
||||
"fullScreen": "Pantalla completa"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Controles de zoom de la pantalla compartida",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomInWithShortcut": "Acercar ({{shortcut}})",
|
||||
"zoomOut": "Alejar",
|
||||
"zoomOutWithShortcut": "Alejar ({{shortcut}})",
|
||||
"wheelShortcut": "Control más rueda del ratón",
|
||||
"wheelShortcutMac": "⌘+rueda del ratón",
|
||||
"wheelShortcutHint": "Atajo: Control más rueda del ratón para acercar o alejar.",
|
||||
"wheelShortcutHintMac": "Atajo: Comando más rueda del ratón para acercar o alejar.",
|
||||
"fitToWindow": "Ajustar a la ventana",
|
||||
"fullScreen": "Pantalla completa",
|
||||
"exitFullScreen": "Salir de pantalla completa",
|
||||
"currentZoomLevel": "Zoom {{level}} %",
|
||||
"panHint": "Zoom {{level}} %. Arrastra con el ratón, o vuelve a la pantalla compartida y usa las flechas para moverte por la imagen.",
|
||||
"fullScreenEntered": "Pantalla completa activada",
|
||||
"fullScreenExited": "Pantalla completa desactivada",
|
||||
"openInSeparateWindow": "Abrir en una ventana separada",
|
||||
"closeSeparateWindow": "Devolver la pantalla compartida a la reunión",
|
||||
"separateWindowOpened": "Pantalla compartida abierta en una ventana separada",
|
||||
"separateWindowClosed": "Pantalla compartida devuelta a la reunión",
|
||||
"separateWindowBlocked": "El navegador bloqueó la ventana separada. Permite las ventanas emergentes para este sitio y vuelve a intentarlo.",
|
||||
"separateWindowTitle": "Pantalla compartida de {{name}}",
|
||||
"separateWindowLabel": "Pantalla compartida de {{name}} en una ventana separada"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Atajos de teclado",
|
||||
"categories": {
|
||||
@@ -774,7 +799,11 @@
|
||||
"raise-hand": "Levantar o bajar la mano",
|
||||
"toggle-chat": "Mostrar/Ocultar el chat",
|
||||
"toggle-participants": "Mostrar/Ocultar los participantes",
|
||||
"open-shortcuts-settings": "Abrir los ajustes de los atajos"
|
||||
"open-shortcuts-settings": "Abrir los ajustes de los atajos",
|
||||
"zoom-in": "Acercar una pantalla compartida",
|
||||
"zoom-out": "Alejar una pantalla compartida",
|
||||
"zoom-reset": "Restablecer el zoom de la pantalla compartida",
|
||||
"zoom-pan": "Moverse por una pantalla compartida ampliada"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
|
||||
@@ -33,15 +33,21 @@
|
||||
"accept": "Accepter"
|
||||
},
|
||||
"transcript": {
|
||||
"waitingForTracks": "La transcription attend un microphone. Activez votre microphone pour permettre à la transcription de démarrer.",
|
||||
"started": "{{name}} a démarré la transcription de la réunion.",
|
||||
"stopped": "{{name}} a arrêté la transcription de la réunion.",
|
||||
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
|
||||
"failed": "La transcription s'est interrompue de façon inattendue et n'a pas pu être sauvegardée.",
|
||||
"aborted": "La transcription n'a pas pu être démarrée.",
|
||||
"requested": "{{name}} a demandé à démarrer la transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"waitingForTracks": "L’enregistrement attend un flux audio ou vidéo. Activez votre microphone ou votre caméra, ou partagez votre écran, pour permettre à l’enregistrement de démarrer.",
|
||||
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
||||
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
|
||||
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
|
||||
"failed": "L'enregistrement s'est interrompu de façon inattendue et n'a pas pu être sauvegardé.",
|
||||
"aborted": "L'enregistrement n'a pas pu être démarré.",
|
||||
"requested": "{{name}} a demandé à démarrer l'enregistrement."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -756,6 +756,31 @@
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"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": "⌘+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é",
|
||||
"openInSeparateWindow": "Ouvrir dans une fenêtre séparée",
|
||||
"closeSeparateWindow": "Ramener le partage d'écran dans la réunion",
|
||||
"separateWindowOpened": "Partage d'écran ouvert dans une fenêtre séparée",
|
||||
"separateWindowClosed": "Partage d'écran ramené dans la réunion",
|
||||
"separateWindowBlocked": "Le navigateur a bloqué la fenêtre séparée. Autorisez les pop-ups pour ce site, puis réessayez.",
|
||||
"separateWindowTitle": "Partage d'écran de {{name}}",
|
||||
"separateWindowLabel": "Partage d'écran de {{name}} dans une fenêtre séparée"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Raccourcis clavier",
|
||||
"categories": {
|
||||
@@ -775,7 +800,11 @@
|
||||
"raise-hand": "Lever ou baisser la main",
|
||||
"toggle-chat": "Afficher/Masquer le chat",
|
||||
"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": {
|
||||
"control": "Contrôle",
|
||||
@@ -785,10 +814,14 @@
|
||||
"shift": "Majuscule",
|
||||
"plus": "plus",
|
||||
"hold": "Maintenir {{key}}",
|
||||
"arrows": "Touches fléchées",
|
||||
"plusKey": "Touche plus",
|
||||
"minusKey": "Touche moins",
|
||||
"noShortcut": "Aucun raccourci"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Maintenir {{key}}"
|
||||
"hold": "Maintenir {{key}}",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
@@ -33,15 +33,21 @@
|
||||
"accept": "Accepteren"
|
||||
},
|
||||
"transcript": {
|
||||
"waitingForTracks": "De transcriptie wacht op een microfoon. Zet uw microfoon aan, zodat de transcriptie kan starten.",
|
||||
"started": "{{name}} is de transcriptie van de vergadering gestart.",
|
||||
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
|
||||
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
|
||||
"failed": "De transcriptie is onverwacht gestopt en kon niet worden opgeslagen.",
|
||||
"aborted": "De transcriptie kon niet worden gestart.",
|
||||
"requested": "{{name}} wil graag de transcriptie starten."
|
||||
},
|
||||
"screenRecording": {
|
||||
"waitingForTracks": "De opname wacht op audio of video. Zet uw microfoon of camera aan of deel uw scherm, zodat de opname kan starten.",
|
||||
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
|
||||
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
|
||||
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
|
||||
"failed": "De opname is onverwacht gestopt en kon niet worden opgeslagen.",
|
||||
"aborted": "De opname kon niet worden gestart.",
|
||||
"requested": "{{name}} wil graag de opname starten."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -756,6 +756,31 @@
|
||||
"muteParticipant": "Demp {{name}}",
|
||||
"fullScreen": "Volledig scherm"
|
||||
},
|
||||
"screenShareZoom": {
|
||||
"toolbarLabel": "Zoombediening voor schermdeling",
|
||||
"zoomIn": "Inzoomen",
|
||||
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
|
||||
"zoomOut": "Uitzoomen",
|
||||
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
|
||||
"wheelShortcut": "Control plus scrollwiel",
|
||||
"wheelShortcutMac": "⌘+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",
|
||||
"openInSeparateWindow": "Openen in een apart venster",
|
||||
"closeSeparateWindow": "Schermdeling terugzetten in de vergadering",
|
||||
"separateWindowOpened": "Schermdeling geopend in een apart venster",
|
||||
"separateWindowClosed": "Schermdeling teruggezet in de vergadering",
|
||||
"separateWindowBlocked": "De browser heeft het aparte venster geblokkeerd. Sta pop-ups toe voor deze site en probeer het opnieuw.",
|
||||
"separateWindowTitle": "Schermdeling van {{name}}",
|
||||
"separateWindowLabel": "Schermdeling van {{name}} in een apart venster"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Sneltoetsen",
|
||||
"categories": {
|
||||
@@ -775,7 +800,11 @@
|
||||
"raise-hand": "Hand opsteken of laten zakken",
|
||||
"toggle-chat": "Chat 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": {
|
||||
"control": "Control",
|
||||
@@ -785,10 +814,14 @@
|
||||
"shift": "Shift",
|
||||
"plus": "plus",
|
||||
"hold": "Houd {{key}} ingedrukt",
|
||||
"arrows": "Pijltoetsen",
|
||||
"plusKey": "Plus-toets",
|
||||
"minusKey": "Min-toets",
|
||||
"noShortcut": "Geen sneltoets"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Houd {{key}} ingedrukt"
|
||||
"hold": "Houd {{key}} ingedrukt",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { flushSync } from 'react-dom'
|
||||
import { proxy, ref } from 'valtio'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import type { ZoomState } from '@/features/rooms/livekit/hooks/useScreenShareZoom'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
|
||||
type Entry = {
|
||||
trackSid: string
|
||||
window: Window
|
||||
container: HTMLElement
|
||||
// Pin that was on screen when the window opened. Restored on close so the
|
||||
// share (or whichever tile was focused) comes back. Absent when the meeting
|
||||
// was already in grid view.
|
||||
pinnedTrack?: TrackReferenceOrPlaceholder
|
||||
detachListeners: () => void
|
||||
}
|
||||
|
||||
export const screenSharePopoutStore = proxy<{ entry: Entry | null }>({
|
||||
entry: null,
|
||||
})
|
||||
|
||||
// Opening and closing both move the video between documents, which remounts
|
||||
// the tile. These two slots carry what must survive that remount. They are
|
||||
// armed by the transitions below, so an unrelated remount keeps its previous
|
||||
// behaviour: the zoom resets and the focus stays where it was.
|
||||
let armedSid: string | null = null
|
||||
let carriedZoom: { trackSid: string; state: ZoomState } | null = null
|
||||
let pendingButtonFocusSid: string | null = null
|
||||
|
||||
export const saveScreenShareZoom = (trackSid: string, state: ZoomState) => {
|
||||
if (armedSid !== trackSid) return
|
||||
// One save per transition, so a later unrelated remount starts from scratch.
|
||||
armedSid = null
|
||||
carriedZoom = { trackSid, state }
|
||||
}
|
||||
|
||||
export const takeScreenShareZoom = (trackSid: string) => {
|
||||
if (carriedZoom?.trackSid !== trackSid) return null
|
||||
const { state } = carriedZoom
|
||||
carriedZoom = null
|
||||
return state
|
||||
}
|
||||
|
||||
export const takePopoutButtonFocus = (trackSid: string) => {
|
||||
if (pendingButtonFocusSid !== trackSid) return false
|
||||
pendingButtonFocusSid = null
|
||||
return true
|
||||
}
|
||||
|
||||
export const openScreenSharePopout = ({
|
||||
trackSid,
|
||||
popup,
|
||||
container,
|
||||
onPopupClosed,
|
||||
}: {
|
||||
trackSid: string
|
||||
popup: Window
|
||||
container: HTMLElement
|
||||
onPopupClosed: () => void
|
||||
}) => {
|
||||
const popupHidden = () => onPopupClosed()
|
||||
// The meeting tab is going away: drop the window rather than run the
|
||||
// bring-back flow, which would touch React while the page tears down.
|
||||
const openerHidden = () => {
|
||||
popup.removeEventListener('pagehide', popupHidden)
|
||||
popup.close()
|
||||
}
|
||||
popup.addEventListener('pagehide', popupHidden, { once: true })
|
||||
window.addEventListener('pagehide', openerHidden)
|
||||
|
||||
const pinnedTrack = layoutStore.pinnedTrackRef
|
||||
armedSid = trackSid
|
||||
screenSharePopoutStore.entry = {
|
||||
trackSid,
|
||||
window: ref(popup),
|
||||
container: ref(container),
|
||||
pinnedTrack: pinnedTrack ? ref(pinnedTrack) : undefined,
|
||||
detachListeners: () => {
|
||||
popup.removeEventListener('pagehide', popupHidden)
|
||||
window.removeEventListener('pagehide', openerHidden)
|
||||
},
|
||||
}
|
||||
// The share (or another pin) was filling the stage. Grid view leaves the
|
||||
// rest of the meeting the whole window while the share is outside.
|
||||
if (pinnedTrack) clearPinnedTrack()
|
||||
}
|
||||
|
||||
// Clears the popout. The previous pin is restored only when the video is
|
||||
// coming back: if the share itself ended, that pin points at a dead track.
|
||||
export const closeScreenSharePopout = ({
|
||||
restorePin,
|
||||
}: {
|
||||
restorePin: boolean
|
||||
}) => {
|
||||
const entry = screenSharePopoutStore.entry
|
||||
if (!entry) return
|
||||
const { window: popup, pinnedTrack, trackSid, detachListeners } = entry
|
||||
detachListeners()
|
||||
|
||||
if (restorePin) {
|
||||
// Both must be set before the render below: it remounts the tile, which
|
||||
// reads them from its layout effect.
|
||||
armedSid = trackSid
|
||||
pendingButtonFocusSid = trackSid
|
||||
// Put the cursor back in the meeting so the tile can focus its button.
|
||||
window.focus()
|
||||
} else {
|
||||
armedSid = null
|
||||
carriedZoom = null
|
||||
pendingButtonFocusSid = null
|
||||
}
|
||||
|
||||
// Unmount the portal while the other document is still alive, and put the
|
||||
// pin back in the same render so the share doesn't flash through the grid.
|
||||
flushSync(() => {
|
||||
screenSharePopoutStore.entry = null
|
||||
if (restorePin && pinnedTrack && !layoutStore.pinnedTrackRef) {
|
||||
setPinnedTrack(pinnedTrack)
|
||||
}
|
||||
})
|
||||
popup.close()
|
||||
armedSid = null
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Helpers for a separate window. Not the meeting PiP: that API allows only
|
||||
// one window, and it is already used. A blank popup has no CSS, so we copy
|
||||
// styles from the meeting.
|
||||
|
||||
const AUXILIARY_ROOT_ID = 'root'
|
||||
|
||||
const copyDocumentChrome = (target: Window) => {
|
||||
const { document: targetDoc } = target
|
||||
document.head
|
||||
.querySelectorAll('link[rel="stylesheet"], style')
|
||||
.forEach((node) => {
|
||||
targetDoc.head.appendChild(node.cloneNode(true))
|
||||
})
|
||||
|
||||
targetDoc.documentElement.className = document.documentElement.className
|
||||
targetDoc.documentElement.style.cssText =
|
||||
document.documentElement.style.cssText
|
||||
targetDoc.documentElement.setAttribute(
|
||||
'lang',
|
||||
document.documentElement.lang || 'en'
|
||||
)
|
||||
|
||||
const theme = document.documentElement.dataset.lkTheme
|
||||
if (theme) {
|
||||
targetDoc.documentElement.dataset.lkTheme = theme
|
||||
}
|
||||
}
|
||||
|
||||
const ensureAuxiliaryRoot = (target: Window) => {
|
||||
const existing = target.document.getElementById(AUXILIARY_ROOT_ID)
|
||||
if (existing) return existing
|
||||
|
||||
const root = target.document.createElement('div')
|
||||
root.id = AUXILIARY_ROOT_ID
|
||||
root.style.width = '100%'
|
||||
root.style.height = '100%'
|
||||
target.document.body.appendChild(root)
|
||||
return root
|
||||
}
|
||||
|
||||
// Fill the window and reuse the meeting colors so it does not flash white.
|
||||
const applyLayout = (target: Window) => {
|
||||
const { document: targetDoc } = target
|
||||
const sourceBody = getComputedStyle(document.body)
|
||||
|
||||
targetDoc.documentElement.style.height = '100%'
|
||||
targetDoc.body.style.margin = '0'
|
||||
targetDoc.body.style.height = '100%'
|
||||
targetDoc.body.style.overflow = 'hidden'
|
||||
targetDoc.body.style.backgroundColor = sourceBody.backgroundColor
|
||||
targetDoc.body.style.color = sourceBody.color
|
||||
}
|
||||
|
||||
// Returns the element to portal into.
|
||||
export const initializeAuxiliaryWindow = (
|
||||
target: Window,
|
||||
{ title }: { title: string }
|
||||
) => {
|
||||
copyDocumentChrome(target)
|
||||
target.document.title = title
|
||||
applyLayout(target)
|
||||
return ensureAuxiliaryRoot(target)
|
||||
}
|
||||
|
||||
// Match the shared screen size, but keep the window on one display.
|
||||
export const getAuxiliaryWindowSize = (
|
||||
video?: Pick<HTMLVideoElement, 'videoWidth' | 'videoHeight'> | null
|
||||
) => {
|
||||
const maxWidth = Math.max(320, Math.round(window.screen.availWidth * 0.9))
|
||||
const maxHeight = Math.max(240, Math.round(window.screen.availHeight * 0.9))
|
||||
const videoWidth = video?.videoWidth ?? 0
|
||||
const videoHeight = video?.videoHeight ?? 0
|
||||
|
||||
if (videoWidth > 0 && videoHeight > 0) {
|
||||
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight, 1)
|
||||
return {
|
||||
width: Math.max(320, Math.round(videoWidth * scale)),
|
||||
height: Math.max(240, Math.round(videoHeight * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: Math.min(1280, maxWidth),
|
||||
height: Math.min(720, maxHeight),
|
||||
}
|
||||
}
|
||||
|
||||
export const getAuxiliaryWindowFeatures = (width: number, height: number) =>
|
||||
`popup=yes,width=${width},height=${height},resizable=yes,scrollbars=no,status=no,location=no,toolbar=no,menubar=no`
|
||||
@@ -74,7 +74,7 @@ spec:
|
||||
value: meet
|
||||
- name: MINIO_ROOT_PASSWORD
|
||||
value: password
|
||||
image: "minio/minio"
|
||||
image: "quay.io/minio/minio"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
@@ -103,7 +103,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: mc
|
||||
image: minio/mc
|
||||
image: quay.io/minio/mc
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.27
|
||||
version: 0.0.28
|
||||
|
||||
+28
-16
@@ -81,18 +81,24 @@
|
||||
| `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` |
|
||||
| `backend.createsuperuser.command` | backend migrate command | `["/bin/sh","-c","while ! python manage.py check --database default > /dev/null 2>&1\ndo\n echo \"Database not ready\"\n sleep 2\ndone\necho \"Database is ready\"\n\npython manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD\n"]` |
|
||||
| `backend.createsuperuser.restartPolicy` | backend migrate job restart policy | `Never` |
|
||||
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` |
|
||||
| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__lbheartbeat__` |
|
||||
| `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` |
|
||||
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `30` |
|
||||
| `backend.probes.liveness.initialDelaySeconds` | Configure timeout for backend liveness probe | `30` |
|
||||
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `undefined` |
|
||||
| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `undefined` |
|
||||
| `backend.probes.liveness.periodSeconds` | Configure period for backend liveness probe | `30` |
|
||||
| `backend.probes.liveness.timeoutSeconds` | Configure timeout for backend liveness probe | `5` |
|
||||
| `backend.probes.liveness.failureThreshold` | Configure failure threshold for backend liveness probe | `undefined` |
|
||||
| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `/__lbheartbeat__` |
|
||||
| `backend.probes.startup.targetPort` | Configure port for backend HTTP startup probe | `undefined` |
|
||||
| `backend.probes.startup.initialDelaySeconds` | Configure initial delay for backend startup probe | `undefined` |
|
||||
| `backend.probes.startup.initialDelaySeconds` | Configure timeout for backend startup probe | `undefined` |
|
||||
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__lbheartbeat__` |
|
||||
| `backend.probes.startup.periodSeconds` | Configure period for backend startup probe | `5` |
|
||||
| `backend.probes.startup.timeoutSeconds` | Configure timeout for backend startup probe | `5` |
|
||||
| `backend.probes.startup.failureThreshold` | Configure failure threshold for backend startup probe | `12` |
|
||||
| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__heartbeat__` |
|
||||
| `backend.probes.readiness.targetPort` | Configure port for backend HTTP readiness probe | `undefined` |
|
||||
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `30` |
|
||||
| `backend.probes.readiness.initialDelaySeconds` | Configure timeout for backend readiness probe | `30` |
|
||||
| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `undefined` |
|
||||
| `backend.probes.readiness.periodSeconds` | Configure period for backend readiness probe | `30` |
|
||||
| `backend.probes.readiness.timeoutSeconds` | Configure timeout for backend readiness probe | `5` |
|
||||
| `backend.probes.readiness.failureThreshold` | Configure failure threshold for backend readiness probe | `3` |
|
||||
| `backend.resources` | Resource requirements for the backend container | `{}` |
|
||||
| `backend.nodeSelector` | Node selector for the backend Pod | `{}` |
|
||||
| `backend.tolerations` | Tolerations for the backend Pod | `[]` |
|
||||
@@ -211,18 +217,24 @@
|
||||
| `summary.service.port` | summary Service listening port | `80` |
|
||||
| `summary.service.targetPort` | summary container listening port | `8000` |
|
||||
| `summary.service.annotations` | Annotations to add to the summary Service | `{}` |
|
||||
| `summary.probes.liveness.path` | Configure path for summary HTTP liveness probe | `/__heartbeat__` |
|
||||
| `summary.probes.liveness.path` | Configure path for summary HTTP liveness probe | `/__lbheartbeat__` |
|
||||
| `summary.probes.liveness.targetPort` | Configure port for summary HTTP liveness probe | `undefined` |
|
||||
| `summary.probes.liveness.initialDelaySeconds` | Configure initial delay for summary liveness probe | `30` |
|
||||
| `summary.probes.liveness.initialDelaySeconds` | Configure timeout for summary liveness probe | `30` |
|
||||
| `summary.probes.startup.path` | Configure path for summary HTTP startup probe | `undefined` |
|
||||
| `summary.probes.liveness.initialDelaySeconds` | Configure initial delay summary liveness probe | `undefined` |
|
||||
| `summary.probes.liveness.periodSeconds` | Configure the period for the summary liveness probe | `30` |
|
||||
| `summary.probes.liveness.timeoutSeconds` | Configure timeout for summary liveness probe | `5` |
|
||||
| `summary.probes.liveness.failureThreshold` | Configure failure threshold for summary liveness probe | `undefined` |
|
||||
| `summary.probes.startup.path` | Configure path for summary HTTP startup probe | `/__lbheartbeat__` |
|
||||
| `summary.probes.startup.targetPort` | Configure port for summary HTTP startup probe | `undefined` |
|
||||
| `summary.probes.startup.initialDelaySeconds` | Configure initial delay for summary startup probe | `undefined` |
|
||||
| `summary.probes.startup.initialDelaySeconds` | Configure timeout for summary startup probe | `undefined` |
|
||||
| `summary.probes.readiness.path` | Configure path for summary HTTP readiness probe | `/__lbheartbeat__` |
|
||||
| `summary.probes.startup.periodSeconds` | Configure period for the summary startup probe | `5` |
|
||||
| `summary.probes.startup.timeoutSeconds` | Configure timeout for summary startup probe | `5` |
|
||||
| `summary.probes.startup.failureThreshold` | Configure failure threshold for summary startup probe | `12` |
|
||||
| `summary.probes.readiness.path` | Configure path for summary HTTP readiness probe | `/__heartbeat__` |
|
||||
| `summary.probes.readiness.targetPort` | Configure port for summary HTTP readiness probe | `undefined` |
|
||||
| `summary.probes.readiness.initialDelaySeconds` | Configure initial delay for summary readiness probe | `30` |
|
||||
| `summary.probes.readiness.initialDelaySeconds` | Configure timeout for summary readiness probe | `30` |
|
||||
| `summary.probes.readiness.initialDelaySeconds` | Configure initial delay for summary readiness probe | `undefined` |
|
||||
| `summary.probes.readiness.periodSeconds` | Configure period for the summary readiness probe | `30` |
|
||||
| `summary.probes.readiness.timeoutSeconds` | Configure timeout for summary readiness probe | `5` |
|
||||
| `summary.probes.readiness.failureThreshold` | Configure failure threshold for summary readiness probe | `3` |
|
||||
| `summary.resources` | Resource requirements for the summary container | `{}` |
|
||||
| `summary.nodeSelector` | Node selector for the summary Pod | `{}` |
|
||||
| `summary.tolerations` | Tolerations for the summary Pod | `[]` |
|
||||
|
||||
@@ -128,6 +128,9 @@ httpGet:
|
||||
{{- end }}
|
||||
initialDelaySeconds: {{ .initialDelaySeconds | eq nil | ternary 0 .initialDelaySeconds }}
|
||||
timeoutSeconds: {{ .timeoutSeconds | eq nil | ternary 1 .timeoutSeconds }}
|
||||
periodSeconds: {{ .periodSeconds | eq nil | ternary 10 .periodSeconds }}
|
||||
failureThreshold: {{ .failureThreshold | eq nil | ternary 3 .failureThreshold }}
|
||||
successThreshold: {{ .successThreshold | eq nil | ternary 1 .successThreshold }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
|
||||
+31
-10
@@ -320,26 +320,36 @@ backend:
|
||||
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe
|
||||
## @param backend.probes.liveness.periodSeconds [nullable] Configure period for backend liveness probe
|
||||
## @param backend.probes.liveness.timeoutSeconds [nullable] Configure timeout for backend liveness probe
|
||||
## @param backend.probes.liveness.failureThreshold [nullable] Configure failure threshold for backend liveness probe
|
||||
## @param backend.probes.startup.path [nullable] Configure path for backend HTTP startup probe
|
||||
## @param backend.probes.startup.targetPort [nullable] Configure port for backend HTTP startup probe
|
||||
## @param backend.probes.startup.initialDelaySeconds [nullable] Configure initial delay for backend startup probe
|
||||
## @param backend.probes.startup.periodSeconds [nullable] Configure period for backend startup probe
|
||||
## @param backend.probes.startup.timeoutSeconds [nullable] Configure timeout for backend startup probe
|
||||
## @param backend.probes.startup.failureThreshold [nullable] Configure failure threshold for backend startup probe
|
||||
## @param backend.probes.readiness.path [nullable] Configure path for backend HTTP readiness probe
|
||||
## @param backend.probes.readiness.targetPort [nullable] Configure port for backend HTTP readiness probe
|
||||
## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for backend readiness probe
|
||||
## @param backend.probes.readiness.periodSeconds [nullable] Configure period for backend readiness probe
|
||||
## @param backend.probes.readiness.timeoutSeconds [nullable] Configure timeout for backend readiness probe
|
||||
## @param backend.probes.readiness.failureThreshold [nullable] Configure failure threshold for backend readiness probe
|
||||
probes:
|
||||
startup:
|
||||
path: /__lbheartbeat__
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 12
|
||||
|
||||
liveness:
|
||||
path: /__heartbeat__
|
||||
initialDelaySeconds: 5
|
||||
path: /__lbheartbeat__
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
|
||||
readiness:
|
||||
path: /__lbheartbeat__
|
||||
initialDelaySeconds: 5
|
||||
path: /__heartbeat__
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
## @param backend.resources Resource requirements for the backend container
|
||||
resources: {}
|
||||
@@ -623,25 +633,36 @@ summary:
|
||||
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure initial delay summary liveness probe
|
||||
## @param summary.probes.liveness.periodSeconds [nullable] Configure the period for the summary liveness probe
|
||||
## @param summary.probes.liveness.timeoutSeconds [nullable] Configure timeout for summary liveness probe
|
||||
## @param summary.probes.liveness.failureThreshold [nullable] Configure failure threshold for summary liveness probe
|
||||
## @param summary.probes.startup.path [nullable] Configure path for summary HTTP startup probe
|
||||
## @param summary.probes.startup.targetPort [nullable] Configure port for summary HTTP startup probe
|
||||
## @param summary.probes.startup.initialDelaySeconds [nullable] Configure initial delay for summary startup probe
|
||||
## @param summary.probes.startup.periodSeconds [nullable] Configure period for the summary startup probe
|
||||
## @param summary.probes.startup.timeoutSeconds [nullable] Configure timeout for summary startup probe
|
||||
## @param summary.probes.startup.failureThreshold [nullable] Configure failure threshold for summary startup probe
|
||||
## @param summary.probes.readiness.path [nullable] Configure path for summary HTTP readiness probe
|
||||
## @param summary.probes.readiness.targetPort [nullable] Configure port for summary HTTP readiness probe
|
||||
## @param summary.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for summary readiness probe
|
||||
## @param summary.probes.readiness.periodSeconds [nullable] Configure period for the summary readiness probe
|
||||
## @param summary.probes.readiness.timeoutSeconds [nullable] Configure timeout for summary readiness probe
|
||||
## @param summary.probes.readiness.failureThreshold [nullable] Configure failure threshold for summary readiness probe
|
||||
probes:
|
||||
liveness:
|
||||
path: /__heartbeat__
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
startup:
|
||||
path: /__lbheartbeat__
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 12
|
||||
|
||||
liveness:
|
||||
path: /__lbheartbeat__
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
|
||||
readiness:
|
||||
path: /__heartbeat__
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
|
||||
## @param summary.resources Resource requirements for the summary container
|
||||
|
||||
@@ -10,6 +10,7 @@ dependencies = [
|
||||
"pydantic-settings>=2.1.0",
|
||||
"celery==5.6.3",
|
||||
"redis==5.2.1",
|
||||
"dockerflow==2026.3.4",
|
||||
"minio==7.2.20",
|
||||
"openai==2.48.0",
|
||||
"posthog==7.29.0",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""API routes related to application health checking."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/__heartbeat__")
|
||||
async def heartbeat():
|
||||
"""Health check endpoint for monitoring."""
|
||||
return
|
||||
|
||||
|
||||
@router.get("/__lbheartbeat__")
|
||||
async def lbheartbeat():
|
||||
"""Health check endpoint for load balancer."""
|
||||
return
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Dockerflow checks backing the /__heartbeat__ endpoint."""
|
||||
|
||||
from celery.exceptions import OperationalError
|
||||
from dockerflow import checks
|
||||
|
||||
from summary.core.celery_worker import celery
|
||||
|
||||
BROKER_SOCKET_TIMEOUT = 2 # Should be under the readiness probe timeout (5s)
|
||||
|
||||
|
||||
@checks.register(name="broker_connected")
|
||||
def check_broker_connected():
|
||||
"""Report whether the Celery broker accepts a connection."""
|
||||
try:
|
||||
with celery.connection_for_write(
|
||||
transport_options={
|
||||
"socket_connect_timeout": BROKER_SOCKET_TIMEOUT,
|
||||
"socket_timeout": BROKER_SOCKET_TIMEOUT,
|
||||
}
|
||||
) as connection:
|
||||
connection.ensure_connection(max_retries=0)
|
||||
except OperationalError as exc:
|
||||
return [checks.Error(str(exc), id="summary.health.E001")]
|
||||
return []
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Application."""
|
||||
|
||||
import sentry_sdk
|
||||
from dockerflow.fastapi import router as dockerflow_router
|
||||
from fastapi import FastAPI
|
||||
|
||||
from summary.api import health
|
||||
from summary.api.main import api_router_v2
|
||||
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
@@ -18,4 +19,4 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
|
||||
app.include_router(health.router)
|
||||
app.include_router(dockerflow_router)
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
"""Integration tests for the health check endpoints."""
|
||||
"""Tests for the health check endpoints."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import OperationalError
|
||||
|
||||
|
||||
class TestHeartbeat:
|
||||
"""Tests for the /__heartbeat__ endpoint."""
|
||||
|
||||
def test_returns_200(self, client):
|
||||
"""The heartbeat endpoint responds with 200 OK without an api_key."""
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
@pytest.fixture(name="broker")
|
||||
def fixture_broker():
|
||||
"""Patch the Celery broker connection the heartbeat check opens."""
|
||||
with mock.patch("summary.core.checks.celery.connection_for_write") as connect:
|
||||
connection = connect.return_value
|
||||
connection.__enter__.return_value = connection
|
||||
yield connection
|
||||
|
||||
|
||||
class TestLBHeartbeat:
|
||||
"""Tests for the /__lbheartbeat__ endpoint."""
|
||||
|
||||
def test_returns_200(self, client):
|
||||
def test_returns_200(self, client, broker):
|
||||
"""The LB heartbeat endpoint responds with 200 OK without an api_key."""
|
||||
response = client.get("/__lbheartbeat__")
|
||||
|
||||
broker.ensure_connection.assert_not_called()
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestHeartbeat:
|
||||
"""Tests for the /__heartbeat__ endpoint."""
|
||||
|
||||
def test_returns_200(self, client, broker):
|
||||
"""The heartbeat endpoint responds with 200 OK without an api_key."""
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["checks"]["broker_connected"] == "ok"
|
||||
broker.ensure_connection.assert_called_once_with(max_retries=0)
|
||||
|
||||
def test_returns_500_when_the_broker_is_unreachable(self, client, broker):
|
||||
"""An unreachable celery broker takes the readiness probe down."""
|
||||
broker.ensure_connection.side_effect = OperationalError("Connection refused")
|
||||
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 500
|
||||
|
||||
payload = response.json()
|
||||
assert payload["status"] == "error"
|
||||
assert payload["checks"]["broker_connected"] == "error"
|
||||
assert (
|
||||
payload["details"]["broker_connected"]["messages"]["summary.health.E001"]
|
||||
== "Connection refused"
|
||||
)
|
||||
|
||||
Generated
+11
@@ -423,6 +423,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dockerflow"
|
||||
version = "2026.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/03/935f8399094611981b37634a7ee078484b3995f744135339e51385638f6c/dockerflow-2026.3.4.tar.gz", hash = "sha256:6cc7fcab2a078d06aeca8b02e22fd7f6766dca322fb30aed6910684dbc08690c", size = 68701, upload-time = "2026-03-04T09:01:28.178Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b9/2d7bf30eb0c24e60e61e4f6ae7574e123d3fa51b39a612e146c94158446a/dockerflow-2026.3.4-py2.py3-none-any.whl", hash = "sha256:38fe0ad30a4aedf68e589919695d502fc6f584a761b56a91f56e80cbadfbf61a", size = 36424, upload-time = "2026-03-04T09:01:26.631Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
@@ -1511,6 +1520,7 @@ version = "1.31.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "celery" },
|
||||
{ name = "dockerflow" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "langfuse" },
|
||||
{ name = "minio" },
|
||||
@@ -1534,6 +1544,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "celery", specifier = "==5.6.3" },
|
||||
{ name = "dockerflow", specifier = "==2026.3.4" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.105.0" },
|
||||
{ name = "langfuse", specifier = "==4.14.1" },
|
||||
{ name = "minio", specifier = "==7.2.20" },
|
||||
|
||||
Reference in New Issue
Block a user