Compare commits

...

54 Commits

Author SHA1 Message Date
Jaya Allamsetty
bd935af7ef test(virtual-background): add V2 e2e tests for studio light, tier override and captureStream
Add VirtualBackgroundDialog page object methods for studio light presets.
Add virtualBackgroundV2.spec.ts with three suites:
- V2 engine: blur, studio light presets, cross-type switching, cancel,
  persistence, rapid switching
- V2 + low tier override: forces TFLite WASM backend via tierOverride
- V2 + captureStream fallback: forces captureStream path via
  useInsertableStreams=false

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:22:33 -04:00
Jaya Allamsetty
1679c4f56a fix(virtual-background): address PR review issues
- Remove duplicate error notification in V2 factory -- let the error
  propagate to toggleBackgroundEffect which already handles it
- Fix worker message listener leak in stopEffect() by tracking and
  removing the pending inference handler before resolving
- Release WebGL context in WebGLCompositor.dispose() via loseContext()
  to avoid hitting the browser ~16 context limit
- Rename _ortSkipCounter to _inferenceSkipCounter since it applies to
  all backends, not just ORT

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
ed707190e3 fix(virtual-background): rename ortSkipStride to inferenceStride throughout 2026-04-09 17:21:20 -04:00
Jaya Allamsetty
cc220c6058 address review feedback 2026-04-09 17:21:20 -04:00
Jaya Allamsetty
deb687f3ef fix(virtual-background): use tier-specific edge thresholds and fix stale config docs.
Introduce per-tier defaults: LOW keeps 0.48, MEDIUM/HIGH use 0.28. Config overrides (edgeLow/edgeHigh) still take precedence.
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
b64c882b0c fix(virtual0background) Uses selfie_segmenter.tflite model for LOW tier instead of the ML Kit model.
This model has faster inference and better segmentation quality on low end devices.
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
5049a5664c fix(virtual-background): replace ORT PP-HumanSeg with ML Kit TFLite for LOW tier and add GPU→TFLite fallback 2026-04-09 17:21:20 -04:00
Jaya Allamsetty
25e9d36a64 fix(virtual-background): improve edge quality, fix deployment issues and studio light
- Raise DEFAULT_EDGE_LOW 0.20→0.30 to reject low-confidence background
    detections (wall decorations, chair backs) without clipping hair edges
  - Add camera-space edge detection (cameraEdgeStrength) in VB shader to
    snap mask at high-contrast boundaries; halve maskBlurRadius target for
    tighter feathering
  - Remove studio light radial focus falloff (hardcoded focusCenter at
    (0.5, 0.35) put the spotlight above the face); effect now applies
    uniformly within the person mask
  - Tune Spotlight preset: reduce warmth and tighten to avoid background bleed
  - Replace dynamic import of JitsiStreamBackgroundEffectV2 with static
    import to prevent webpack chunk splitting and ChunkLoadError on deploy
  - Normalize getBaseUrl() trailing slash to fix malformed worker/model URLs
  - Add vb-inference-worker.min.js to Makefile deploy-appbundle target
  - Move ORT WASM files from libs/ort/ subdir directly into libs/.
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
d93dc6870d feat(virtual-background) Adds studio light presets with background dimming and tuned shader parameters.
> - Natural: minimal enhancement, nearly transparent processing
> - Spotlight: face brightly lit against a 40% dimmed background with warm tone
> - Soft Focus: beauty/glamour filter with skin smoothing, glow bloom, and reduced contrast
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
88c3b9c974 feat(virtual-background) Adds v2 engine with worker-based inference, insertable streams and device tier detection.
Introduces JitsiStreamBackgroundEffectV2 alongside the existing V1 effect, opt-in via
  config.virtualBackground.enableV2.

- VBInferenceWorker: all inference runs in a dedicated Worker thread — MEDIUM/HIGH tiers
  use TF.js body-segmentation on an OffscreenCanvas WebGL/WebGPU context (immune to
  Chrome's tab-visibility GPU throttle); LOW tier uses onnxruntime-web@1.17.3 with
  PP-HumanSeg FP32 192×192 WASM
- Insertable streams path: MediaStreamTrackProcessor/Generator as the default frame driver;
    captureStream + rVFC/keepalive-Worker used as fallback
- DeviceTierDetector: localStorage hardware-tier caching so the WebGPU/WebGL probe runs once
- WebGLCompositor: add Gaussian blur kernel in the shader; remove dead methods and textures
- CPU-side EMA mask smoothing replaces GPU ping-pong temporal blending
- ORT frame skipping via ortSkipStride (default 2, ~25fps pipeline at ~12Hz mask rate)
- VideoFrame GC fix: stopEffect() cancels the IS reader immediately to flush buffered frames
- initPromise exposed for async-init error propagation in toggleBackgroundEffect and createLocalTracksF
- requiresPostConstructionApplication getter to safely apply IS-path effects post-construction
- Benchmark logging gated on config.virtualBackground.testMode
- Adds onnxruntime-web@1.17.3, vb-inference-worker webpack entry, ORT/model Makefile deploy targets.
2026-04-09 17:21:20 -04:00
Jaya Allamsetty
279638111a fix(tests): wait for aria-checked after VB thumbnail clicks
Rapid clicks in the virtual background dialog could be swallowed when
React re-renders between clicks. Each click method now waits for
aria-checked=true before returning, ensuring the re-render completes
before the next interaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:20:13 -04:00
damencho
e5fb124baf fix(tests): Fixes missing wait. 2026-04-08 12:07:17 -05:00
damencho
c09719dabf test(wdio): skip JaaS tests at capability level when JaaS is not configured
Exclude tests with useJaas:true from all browser capabilities when
JaaS is not configured, preventing unnecessary worker and browser
creation before the before() hook can skip them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:07:17 -05:00
Jaya Allamsetty
41ba851275 feat(tests) adds e2e tests for virtual background dialog
Add page object and WebDriverIO spec covering dialog open, blur/none
selection, aria-checked state, Redux state validation, cancel discard,
rapid switching, and persistence across reopen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:34:26 -04:00
bgrozev
db49f6fd45 test: Set asyncTranscriptionWebhook=true by default. (#17279)
* test: Set asyncTranscriptionWebhook=true by default.

* test: Do not compare messageId, language for async transcription webhooks.
2026-04-08 10:32:57 -05:00
Christoph Settgast
144bc6529d lang: update German translation (#17285) 2026-04-07 21:57:35 +02:00
damencho
ac84afae7c fix(prosody): Drops not needed checks. 2026-04-07 06:05:45 -05:00
damencho
3f316dde56 fix(webpack-proxy): Fixes make dev. 2026-04-06 09:29:32 -05:00
Christoph Settgast
c08a1d4b11 lang: Update German translation 2026-04-05 18:12:17 -05:00
damencho
f132ed6c5c fix: Fixes base for dynamic loaded libs. 2026-04-03 08:37:04 -05:00
Calinteodor
fc4186b052 feat(whiteboard): updates (#17274)
* Updates around prevent localStorage null crash and excalidraw version.
2026-04-03 16:19:22 +03:00
Hristo Terezov
80ad2c9d16 fix(breakout-rooms): bypass disableInitialGUM when joining breakout room
When disableInitialGUM is enabled, moving to a breakout room would
skip creating initial local tracks, leaving the user without audio
and video. This ensures GUM is always requested when entering a
breakout room regardless of the disableInitialGUM config setting.
2026-04-02 16:07:04 -05:00
Calinteodor
4f06c43eb7 feat(whiteboard): updates dependencies and drop not used features
* feat(whiteboard): remove image from toolbar

* dep(@jitsi/excalidraw): update version

* firebase was already removed from excalidraw so no need to resolve it

* dep(@jitsi/excalidraw): update to 0.18.3 and patch

* feat(whiteboard): disableFileDrop

* dep(@jitsi/excalidraw): update to 0.18.4
2026-04-02 12:23:21 -05:00
damencho
5a11cd8801 feat(util): Lazy load the insecure room name checker. 2026-04-01 15:08:18 -05:00
damencho
d11a9daf41 feat(whiteboard): Lazy load the whiteboard dependeny. 2026-04-01 15:08:18 -05:00
bgrozev
6f4412db6e fix(tests): Report session-init crash in allure report. (#17263)
When the WebDriver session POST times out (grid unavailable at init time),
the JUnit reporter writes a non-empty XML with empty name/classname, but the
allure reporter never fires since no beforeTest hook ran. The onWorkerEnd
hook previously bailed out on any non-empty XML, so the failure was invisible
in the allure report.

Now the hook reads the XML content to distinguish real test results (named
testcases) from a crashed-runner placeholder (empty name). Only in the latter
case does it synthesise allure and JUnit entries.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:48:10 -05:00
bgrozev
8ff122720d fix(prosody-plugins): redact transcription HTTP header values from metadata log (#17258)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:35:29 -05:00
Hristo Terezov
57b820f77f feat(keyboard-shortcuts): support Ctrl+Alt modifier combos in registerShortcut
Allow shortcuts to specify both ctrl and alt flags, enabling key combos
like Ctrl+Alt+D. The key encoding uses `-:` prefix to distinguish from
plain Alt (`:`) or plain Ctrl (`-`) shortcuts.
2026-04-01 09:39:08 -05:00
Calin-Teodor
9b2b909b72 feat(base/ui): added testID 2026-04-01 13:05:14 +03:00
Hristo Terezov
9a3917942f fix(custom-panel): remove close button from custom panel 2026-03-31 11:13:32 -05:00
Дамян Минков
2d6be4ea27 fix: Fixes excalidraw in the deb package.
* fix: Fixes excalidraw in the deb package.

* squash: Drops unused files and moves the fonts to the excalidraw prod folder.

* squash: Rename prod folder to excalidraw.

* squash: Fix eslint.
2026-03-31 10:31:32 -05:00
Christoph Settgast
cfd790731d lang: update German translation
Signed-off-by: Christoph Settgast <csett86_git@quicksands.de>
2026-03-31 08:41:31 -05:00
Avram Tudor
04dc27b9e8 fix(dynamic-branding): custom palette colors not applied to semantic tokens (#17252)
Fixes a regression introduced by semantic tokens, released in 6626 where components switched from using base tokens (e.g., action01) to semantic tokens (e.g., prejoinActionButtonPrimary).

This caused custom branding palette overrides to stop working because semantic tokens were resolved BEFORE the custom palette was merged.
2026-03-31 16:00:40 +03:00
Mihaela Dumitru
6534e6f277 feat(recording): Add separate transcription indicator with contextual notifications (#17153) 2026-03-31 13:25:13 +03:00
Calinteodor
5e58c9b133 fix: migrate to new Excalidraw v0.18.0 - update (#17244)
* Update version, suppress build errors without adding Firebase related dependencies and raise the app bundle limit.

---------

Co-authored-by: Yash <139779840+yashop7@users.noreply.github.com>
2026-03-31 12:33:03 +03:00
Дамян Минков
2c43785f54 * feat(visitors): Remove duplicate check.
* feat(visitors): Remove duplicate check.

Any service can be added to ignore list.

* squash: drop list usage.

* squash: Drops unused util.
2026-03-30 14:58:44 -05:00
Hristo Terezov
e672aaa35a feat(custom-panel): add resizable panel width with drag handle and persistence
Allow users to resize the custom panel by dragging a handle on its left
edge, matching the chat panel's resize UX. The panel width is persisted
via PersistenceRegistry so it survives page reloads.

Architectural changes:
- Split CustomPanel into container (resize/close) + CustomPanelContent (branding override)
- Split functions.ts into core functions + functions.custom.ts (branding override)
- Split middleware.web.ts into orchestrator + middleware.custom.web.ts (branding override)
- Chat max size now accounts for custom panel width for mutual awareness

	test-localStorage1.html
2026-03-30 13:36:29 -05:00
Dhiraj Rathod
95c420cab9 chore(config) add deprecated JSDoc annotations to legacy config options
Fixes #16911
2026-03-30 11:17:09 +02:00
Ata Ul Hai
2d534bf2f7 fix(chat): replace kebab-case vendor style with camelCase 2026-03-30 11:15:55 +02:00
Nitin Kumar
d93b1c2195 types(chat): narrow IMessage.messageType using literal union types 2026-03-30 11:15:09 +02:00
Subhajit578
5669d5f63e fix(overlay) refactor and move logic to a middleware 2026-03-30 11:14:22 +02:00
Subhajit578
aac7606b32 fix(chat) store replyToId in Redux 2026-03-30 11:13:06 +02:00
Subhajit578
716ceb0930 fix(external-api) add messageId and replyToMessageId in incoming-message 2026-03-30 11:12:03 +02:00
Robert Fiałek
7574649887 fix(lang) updated Polish translation 2026-03-30 11:10:53 +02:00
Subhajit578
e231d7815e chore(invite): remove unused DialInInfoApp web bootstrap
Dial-in info is served via static/dialInInfo.html using
JitsiMeetJS.app.entryPoints.DIALIN (DialInSummaryApp). The standalone
DialInInfoApp.web.tsx was never imported and duplicated legacy bootstrapping.

Made-with: Cursor
2026-03-27 23:40:40 +01:00
Subhajit578
d51c9eed6f fix(i18n) use Trans in StartLiveStreamDialog 2026-03-27 23:39:43 +01:00
yanas
2cc6953b60 test(desktop-sharing): add multi-stream screensharing tests
Add three new tests to cover scenarios where camera and screenshare
are active simultaneously:
- Camera on when screenshare starts — verifies remotes see both tiles
- Video muted, start screenshare, enable camera — verifies both tiles appear
- Alternating camera and screenshare toggling — verifies no breakage across sequences
2026-03-27 16:54:25 -05:00
Calinteodor
751d1fae36 Revert "fix(react-native-sdk): Export JitsiMeeting component" (#17215)
*This reverts commit 048791c858 and adds a new CI job for RNSDK.
2026-03-27 10:40:03 +02:00
damencho
e9daf4395e fix(prosody): Drops not needed config for jigasi-invite module. 2026-03-26 16:35:33 -05:00
damencho
2245e4e747 fix(prosody): Adds some nil checks. 2026-03-26 16:35:33 -05:00
damencho
43132a7eba chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/releases/tag/v2143.0.0+733e66c6
2026-03-26 14:37:40 -05:00
damencho
4f3dc195cf fix(recording): On missing session id, send stop. 2026-03-26 14:37:40 -05:00
damencho
a80a732ec4 fix(webpack-dev-server-proxy): Allow all hosts.
Avoids errors when accessed via a website (the test-lab).
2026-03-26 14:37:40 -05:00
Daniel Nylander
04757f103e lang: Complete Swedish (sv) translation
* l10n: Complete Swedish (sv) translation — add 26 missing strings

Add Swedish translations for 26 missing UI strings:
- File sharing labels (upload, delete notifications)
- Nickname dialog for chat/polls/file sharing features
- Login error messages
- Screen sharing system stop dialog
- Virtual background limit messages
- Toolbar labels (copilot, file sharing, polls)

This brings Swedish translation to 100% coverage (1492/1492 keys).

* l10n(sv): Fix lint + quality improvements

- Add trailing newline (fixes CI Lint check)
- Fix 33 strings: denna/detta/dessa → den här/det här/de här
- Remove 'vänligen' (overly formal)
- Fix English leftover in demoteParticipantDialog
- Fix permission error messages

* l10n(sv): Fix untranslated string (searchResultsTryAgain)

* Fix Swedish translation JSON formatting

- Properly sort and format translation keys per project standards
- Fixes CI lint failure in PR #17206

---------

Co-authored-by: Daniel Nylander <daniel@danielnylander.se>
2026-03-26 09:25:40 -05:00
162 changed files with 10419 additions and 670 deletions

View File

@@ -208,6 +208,24 @@ jobs:
-framework ios/sdk/out/ios-device.xcarchive/Products/Library/Frameworks/JitsiMeetSDK.framework \
-output ios/sdk/out/JitsiMeetSDK.xcframework
- run: ls -lR ios/sdk/out
react-native-sdk-build:
name: Build mobile SDK (React Native)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f #v6.3.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Check Node / npm versions
run: |
node -v
npm -v
- run: npm install
- run: |
cd react-native-sdk
node update_sdk_dependencies.js
npm install
debian-build:
name: Test Debian packages build
runs-on: ubuntu-latest

View File

@@ -5,10 +5,11 @@ LIBJITSIMEET_DIR = node_modules/lib-jitsi-meet
OLM_DIR = node_modules/@matrix-org/olm
TF_WASM_DIR = node_modules/@tensorflow/tfjs-backend-wasm/dist/
RNNOISE_WASM_DIR = node_modules/@jitsi/rnnoise-wasm/dist
EXCALIDRAW_DIR = node_modules/@jitsi/excalidraw/dist/excalidraw-assets
EXCALIDRAW_DIR_DEV = node_modules/@jitsi/excalidraw/dist/excalidraw-assets-dev
EXCALIDRAW_DIR = node_modules/@jitsi/excalidraw/dist/prod
EXCALIDRAW_DIR_DEV = node_modules/@jitsi/excalidraw/dist/dev
TFLITE_WASM = react/features/stream-effects/virtual-background/vendor/tflite
MEET_MODELS_DIR = react/features/stream-effects/virtual-background/vendor/models
MEDIAPIPE_SEGMENTATION_DIR = node_modules/@mediapipe/selfie_segmentation
FACE_MODELS_DIR = node_modules/@vladmandic/human-models/models
NODE_SASS = ./node_modules/.bin/sass
NPM = npm
@@ -34,7 +35,7 @@ clean:
rm -fr $(BUILD_DIR)
.NOTPARALLEL:
deploy: deploy-init deploy-appbundle deploy-rnnoise-binary deploy-excalidraw deploy-tflite deploy-meet-models deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-css deploy-local deploy-face-landmarks
deploy: deploy-init deploy-appbundle deploy-rnnoise-binary deploy-excalidraw deploy-tflite deploy-meet-models deploy-mediapipe-segmentation deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-css deploy-local deploy-face-landmarks
deploy-init:
rm -fr $(DEPLOY_DIR)
@@ -54,11 +55,14 @@ deploy-appbundle:
$(BUILD_DIR)/noise-suppressor-worklet.min.js.map \
$(BUILD_DIR)/screenshot-capture-worker.min.js \
$(BUILD_DIR)/screenshot-capture-worker.min.js.map \
$(BUILD_DIR)/vb-inference-worker.min.js \
$(BUILD_DIR)/vb-inference-worker.min.js.map \
$(DEPLOY_DIR)
cp \
$(BUILD_DIR)/close3.min.js \
$(BUILD_DIR)/close3.min.js.map \
$(DEPLOY_DIR) || true
cp -r $(BUILD_DIR)/chunks $(DEPLOY_DIR)/chunks
deploy-lib-jitsi-meet:
cp \
@@ -86,20 +90,25 @@ deploy-tflite:
$(DEPLOY_DIR)
deploy-excalidraw:
cp -R \
$(EXCALIDRAW_DIR) \
$(DEPLOY_DIR)/
mkdir -p $(DEPLOY_DIR)/excalidraw
cp -R $(EXCALIDRAW_DIR)/fonts $(DEPLOY_DIR)/excalidraw/
deploy-excalidraw-dev:
cp -R \
$(EXCALIDRAW_DIR_DEV) \
$(DEPLOY_DIR)/
mkdir -p $(DEPLOY_DIR)/excalidraw
cp -R $(EXCALIDRAW_DIR_DEV)/fonts $(DEPLOY_DIR)/excalidraw/
deploy-meet-models:
cp \
$(MEET_MODELS_DIR)/*.tflite \
$(MEET_MODELS_DIR)/selfie_segmentation_landscape.tflite \
$(MEET_MODELS_DIR)/selfie_segmenter.tflite \
$(DEPLOY_DIR)
deploy-mediapipe-segmentation:
mkdir -p $(DEPLOY_DIR)/mediapipe-segmentation
cp \
$(MEDIAPIPE_SEGMENTATION_DIR)/selfie_segmentation* \
$(DEPLOY_DIR)/mediapipe-segmentation
deploy-face-landmarks:
cp \
$(FACE_MODELS_DIR)/blazeface-front.bin \
@@ -117,7 +126,7 @@ deploy-local:
([ ! -x deploy-local.sh ] || ./deploy-local.sh)
.NOTPARALLEL:
dev: deploy-init deploy-css deploy-rnnoise-binary deploy-tflite deploy-meet-models deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-excalidraw-dev deploy-face-landmarks
dev: deploy-init deploy-css deploy-rnnoise-binary deploy-tflite deploy-meet-models deploy-mediapipe-segmentation deploy-lib-jitsi-meet deploy-olm deploy-tf-wasm deploy-excalidraw-dev deploy-face-landmarks
$(WEBPACK_DEV_SERVER)
source-package: compile deploy

View File

@@ -389,6 +389,7 @@ export default {
* Returns an object containing a promise which resolves with the created tracks &
* the errors resulting from that process.
* @param {object} options
* @param {boolean} option.isBreakoutRoom - true if we are creating the initial local tracks in breakout room.
* @param {boolean} options.startAudioOnly=false - if <tt>true</tt> then
* only audio track will be created and the audio only mode will be turned
* on.
@@ -403,14 +404,16 @@ export default {
*/
createInitialLocalTracks(options = {}, recordTimeMetrics = false) {
const errors = {};
const { isBreakoutRoom = false } = options;
// Always get a handle on the audio input device so that we have statistics (such as "No audio input" or
// "Are you trying to speak?" ) even if the user joins the conference muted.
const initialDevices = config.startSilent || config.disableInitialGUM ? [] : [ MEDIA_TYPE.AUDIO ];
const requestedAudio = !config.disableInitialGUM;
const initialDevices
= config.startSilent || (config.disableInitialGUM && !isBreakoutRoom) ? [] : [ MEDIA_TYPE.AUDIO ];
const requestedAudio = !config.disableInitialGUM || isBreakoutRoom;
let requestedVideo = false;
if (!config.disableInitialGUM
if ((!config.disableInitialGUM || isBreakoutRoom)
&& !options.startWithVideoMuted
&& !options.startAudioOnly
&& !options.startScreenSharing) {

View File

@@ -1499,6 +1499,56 @@ var config = {
// Sets the background transparency level. '0' is fully transparent, '1' is opaque.
// backgroundAlpha: 1,
// Virtual background V2 options (MediaPipe body-segmentation + WebGL compositor).
// All fields are optional; omitting a field uses the default/auto-detected value.
virtualBackground: {
// Enable the V2 processing engine. When false (default), the legacy
// TFLite WASM engine (V1) is used. Set to true to opt in to V2.
// enableV2: false,
// Force a specific device tier regardless of what the browser supports.
// Useful for testing lower-tier behaviour on high-end hardware.
// Values: 'high' | 'medium' | 'low' — null means auto-detect (default).
// tierOverride: null,
// Override the segmentation canvas dimensions (pixels). Applies to MEDIUM and HIGH
// tiers only (TF.js input canvas). LOW tier (TFLite) always runs at 256×256,
// fixed by the selfie_segmenter model and not affected by this setting.
// segmentationWidth: null, // auto: 512 (high) / 384 (medium)
// segmentationHeight: null, // auto: 288 (high) / 216 (medium)
// Override the target frame rate for the effect.
// targetFps: null, // auto: 30 (all tiers)
// Temporal mask blend ratio (0-1). Higher = smoother motion, slower to respond
// to fast movement. 0 = raw mask each frame (no temporal smoothing).
// temporalBlendRatio: 0.75,
// Smoothstep edge thresholds for the WebGL compositor (0-1).
// Pixels with segmentation confidence below edgeLow are fully transparent;
// above edgeHigh they are fully opaque; between the two they feather.
// Defaults are tier-specific:
// LOW tier (TFLite): edgeLow = 0.48, edgeHigh = 0.65
// MEDIUM/HIGH (TF.js): edgeLow = 0.28, edgeHigh = 0.65
// Lower edgeLow = more hair retained at the cost of slight background bleed.
// Higher edgeHigh = harder edge transition.
// edgeLow: 0.28,
// edgeHigh: 0.65,
// Insertable Streams (MediaStreamTrackProcessor/Generator) is used by default
// when available. It reduces latency by ~1-2 frames and eliminates the keepalive
// Web Worker. Set to false to force the legacy captureStream path instead.
// useInsertableStreams: false,
// LOW tier (TFLite) inference stride. Inference is skipped on alternate frames;
// skipped frames reuse the previous mask. Higher values = lower CPU usage at the
// cost of reduced mask update frequency. Set to 1 to run inference every frame.
// 1 = every frame (24 fps mask updates, ~37 ms slack per frame) ← default
// 2 = every 2 frames (12 fps mask updates, ~74 ms slack per frame)
// inferenceStride: 1,
},
// The URL of the moderated rooms microservice, if available. If it
// is present, a link to the service will be rendered on the welcome page,
// otherwise the app doesn't render it.
@@ -1863,6 +1913,10 @@ var config = {
// userLimit: 25,
// // The url for more info about the whiteboard and its usage limitations.
// limitUrl: 'https://example.com/blog/whiteboard-limits',
// //Backend URL for storing whiteboard scenes and images
// //This backend service handles scene persistence and file uploads
// storageBackendUrl: 'https://excalidraw-s3-storage-backend.example.com',
// },
// The watchRTC initialize config params as described :

5
custom.d.ts vendored
View File

@@ -1,3 +1,8 @@
declare module '*.css' {
const content: Record<string, string>;
export default content;
}
declare module '*.svg' {
const content: any;
export default content;

10
globals.d.ts vendored
View File

@@ -18,6 +18,16 @@ declare global {
JITSI_MEET_LITE_SDK?: boolean;
interfaceConfig?: any;
JitsiMeetJS?: any;
MediaStreamTrackGenerator: {
new(options: { kind: string }): MediaStreamTrack & {
writable: WritableStream<VideoFrame>;
};
};
MediaStreamTrackProcessor: {
new(options: { track: MediaStreamTrack; maxBufferSize?: number }): {
readable: ReadableStream<VideoFrame>;
};
};
PressureObserver?: any;
PressureRecord?: any;
ReactNativeWebView?: any;

View File

@@ -22,7 +22,7 @@
: pathname.substring(0, contextRootEndIndex + 1)
);
}
window.EXCALIDRAW_ASSET_PATH = 'libs/';
window.EXCALIDRAW_ASSET_PATH = 'libs/excalidraw/';
// Dynamically generate the manifest location URL. It must be served from the document origin, and we may have
// the base pointing to the CDN. This way we can generate a full URL which will bypass the base.
document.querySelector('#manifest-placeholder').setAttribute('href', window.location.origin + contextRoot(window.location.pathname) + 'manifest.json');

View File

@@ -10,7 +10,7 @@
"copyLink": "Konferenzlink kopieren",
"copyStream": "Livestreaminglink kopieren",
"countryNotSupported": "Wir unterstützen dieses Land noch nicht.",
"countryReminder": "Telefonnummer nicht in den USA? Bitte sicherstellen, dass die Telefonnummer mit dem Ländercode beginnt.",
"countryReminder": "Telefonnummer nicht in den USA? Bitte sicherstellen, dass die Telefonnummer mit dem Ländercode beginnt!",
"defaultEmail": "Ihre Standard-E-Mail",
"disabled": "Sie können keine Personen einladen.",
"failedToAdd": "Fehler beim Hinzufügen von Personen",
@@ -359,7 +359,7 @@
"error": "Fehler",
"errorRoomCreationRestriction": "Sie haben versucht, zu schnell beizutreten, bitte versuchen Sie es gleich noch einmal.",
"gracefulShutdown": "Der Dienst steht momentan wegen Wartungsarbeiten nicht zur Verfügung. Bitte versuchen Sie es später noch einmal.",
"grantModeratorDialog": "Möchten Sie wirklich Moderationsrechte an diese Person vergeben?",
"grantModeratorDialog": "Möchten Sie wirklich Moderationsrechte an {{participantName}} vergeben?",
"grantModeratorTitle": "Moderationsrechte vergeben",
"hide": "Ausblenden",
"hideShareAudioHelper": "Diese Meldung nicht mehr anzeigen",
@@ -385,6 +385,7 @@
"login": "Anmelden",
"loginFailed": "Anmeldung fehlgeschlagen.",
"loginOnResume": "Ihre Anmeldung ist abgelaufen. Sie müssen sich neu anmelden um weiter an der Konferenz teilzunehmen.",
"loginPopupBlocked": "Das Popup für die Anmeldung wurde von Ihrem Browser blockiert.",
"loginQuestion": "Sind Sie sicher, dass sie sich anmelden und die Konferenz verlassen möchten?",
"logoutQuestion": "Sind Sie sicher, dass Sie sich abmelden und die Konferenz verlassen möchten?",
"logoutTitle": "Abmelden",
@@ -395,7 +396,7 @@
"micNotSendingData": "Gehen Sie zu den Einstellungen Ihres Computers, um die Stummschaltung Ihres Mikrofons aufzuheben und seinen Pegel einzustellen",
"micNotSendingDataTitle": "Ihr Mikrofon ist durch Ihre Systemeinstellungen stumm geschaltet",
"micPermissionDeniedError": "Die Berechtigung zur Verwendung des Mikrofons wurde nicht erteilt. Sie können trotzdem an der Konferenz teilnehmen, aber die anderen Personen können Sie nicht hören. Verwenden Sie die Kamera-Schaltfläche in der Adressleiste, um die Berechtigungen zu erteilen.",
"micTimeoutError": "Audioquelle konnte nicht gestartet werden. Zeitüberschreitung",
"micTimeoutError": "Audioquelle konnte nicht gestartet werden. Zeitüberschreitung!",
"micUnknownError": "Das Mikrofon kann aus einem unbekannten Grund nicht verwendet werden.",
"moderationAudioLabel": "Erlaube Anwesenden die Stummschaltung für sich aufzuheben",
"moderationDesktopLabel": "Erlaube Anwesenden ihren Bildschirm freizugeben",
@@ -434,8 +435,8 @@
"muteParticipantsVideoTitle": "Die Kamera von dieser Person ausschalten?",
"noDropboxToken": "Kein gültiges Dropbox-Token",
"password": "Passwort",
"passwordLabel": "Diese Konferenz wurde gesichert. Bitte geben Sie das $t(lockRoomPasswordUppercase) ein, um der Konferenz beizutreten.",
"passwordNotSupported": "Das Festlegen eines Konferenzpassworts wird nicht unterstützt.",
"passwordLabel": "Diese Konferenz wurde gesichert. Bitte geben Sie das $t(lockRoomPassword) ein, um der Konferenz beizutreten.",
"passwordNotSupported": "Das Festlegen eines $t(lockRoomPassword) wird nicht unterstützt.",
"passwordNotSupportedTitle": "$t(lockRoomPasswordUppercase) nicht unterstützt",
"passwordRequired": "$t(lockRoomPasswordUppercase) erforderlich",
"permissionCameraRequiredError": "Der Zugriff auf die Kamera wird benötigt, um in Videokonferenzen teilzunehmen. Bitte in den Einstellungen zulassen",
@@ -456,7 +457,7 @@
"remoteControlStopMessage": "Die Fernsteuerung wurde beendet!",
"remoteControlTitle": "Fernsteuerung",
"remoteUserControls": "Remote Benutzersteuerung von {{username}}",
"removePassword": "$t(lockRoomPasswordUppercase) entfernen",
"removePassword": "$t(lockRoomPassword) entfernen",
"removeSharedVideoMsg": "Sind Sie sicher, dass Sie das geteilte Video entfernen möchten?",
"removeSharedVideoTitle": "Freigegebenes Video entfernen",
"renameBreakoutRoomLabel": "Raumname",
@@ -468,6 +469,8 @@
"screenSharingFailed": "Ups! Beim Teilen des Bildschirms ist etwas schiefgegangen!",
"screenSharingFailedTitle": "Bildschirmfreigabe fehlgeschlagen!",
"screenSharingPermissionDeniedError": "Ups! Etwas stimmt nicht mit Ihren Berechtigungen zur Bildschirmfreigabe. Bitte neu laden und erneut versuchen.",
"screenshareStoppedDiskSpace": "Dies passiert, wenn Sie die Bildschirmfreigabe über die macOS-Menüleiste gestoppt haben. Die Bildschirmfreigabe kann auch bei geringem freien Speicherplatz gestoppt werden.",
"screenshareStoppedTitle": "Bildschirmfreigabe vom Betriebssystem gestoppt",
"searchInSalesforce": "In Salesforce suchen",
"searchResults": "Suchergebnisse({{count}})",
"searchResultsDetailsError": "Beim Abrufen der Daten des Besitzers ist ein Fehler aufgetreten.",
@@ -481,7 +484,7 @@
"serviceUnavailable": "Dienst nicht verfügbar",
"sessTerminated": "Konferenz beendet",
"sessTerminatedReason": "Die Konferenz wurde beendet",
"sessionRestarted": "Konferenz neugestartet",
"sessionRestarted": "Konferenz neugestartet.",
"shareAudio": "Fortfahren",
"shareAudioAltText": "Um den gewünschten Inhalt zu teilen: Navigiere zu \"Browser tab\", wähle den Inhalt, aktiviere \"Audio teilen\" Kästchen Und klicke den “Teilen” schaltfläche",
"shareAudioTitle": "Wie kann Audio geteilt werden",
@@ -615,8 +618,8 @@
},
"info": {
"accessibilityLabel": "Informationen anzeigen",
"addPassword": "$t(lockRoomPasswordUppercase) hinzufügen",
"cancelPassword": "$t(lockRoomPasswordUppercase) löschen",
"addPassword": "$t(lockRoomPassword) hinzufügen",
"cancelPassword": "$t(lockRoomPassword) löschen",
"conferenceURL": "Link:",
"copyNumber": "Nummer kopieren",
"country": "Land",
@@ -646,7 +649,7 @@
"noRoom": "Keine Konferenz für die Einwahlinformationen angegeben.",
"noWhiteboard": "Whiteboard konnte nicht geladen werden.",
"numbers": "Einwahlnummern",
"password": "$t(lockRoomPasswordUppercase):",
"password": "$t(lockRoomPasswordUppercase): ",
"reachedLimit": "Sie haben die Grenzen Ihres Tarifs erreicht.",
"sip": "SIP-Adresse",
"sipAudioOnly": "SIP-Adresse (nur Ton)",
@@ -698,12 +701,13 @@
"changeSignIn": "Konten wechseln.",
"choose": "Livestream auswählen",
"chooseCTA": "Streaming-Option auswählen. Sie sind aktuell als {{email}} angemeldet.",
"chooseCTAWithChangeSignIn": "Streaming-Option auswählen. Sie sind aktuell als {{email}} angemeldet. <0>Account wechseln.</0>",
"enterStreamKey": "Streamschlüssel für den YouTube-Livestream hier eingeben.",
"error": "Das Livestreaming ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"errorAPI": "Beim Abrufen der YouTube-Livestreams ist ein Fehler aufgetreten. Bitte versuchen Sie, sich erneut anzumelden.",
"errorLiveStreamNotEnabled": "Livestreaming ist für {{email}} nicht aktiviert. Aktivieren Sie das Livestreaming oder melden Sie sich bei einem Konto mit aktiviertem Livestreaming an.",
"expandedOff": "Livestream wurde angehalten",
"expandedOn": "Die Konferenz wird momentan an YouTube gestreamt.",
"expandedOn": "Die Konferenz wird momentan an YouTube gestreamt",
"expandedPending": "Livestream wird gestartet …",
"failedToStart": "Livestream konnte nicht gestartet werden",
"getStreamKeyManually": "Wir waren nicht in der Lage, Livestreams abzurufen. Versuchen Sie, Ihren Livestream-Schlüssel von YouTube zu erhalten.",
@@ -726,6 +730,7 @@
"streamIdHelp": "Was ist das?",
"title": "Livestream",
"unavailableTitle": "Livestreaming nicht verfügbar",
"youTubeGoLiveWarning": "Denken Sie daran 'Go Live' im YouTube Studio auszuwählen, wenn Auto-Start/Auto-Stop deaktiviert ist. Ansonsten wird die Aufnahme nicht gestartet.",
"youtubeTerms": "YouTube-Nutzungsbedingungen"
},
"lobby": {
@@ -746,7 +751,7 @@
"joinRejectedTitle": "Beitrittsanfrage abgelehnt.",
"joinTitle": "Konferenz beitreten",
"joinWithPasswordMessage": "Beitrittsversuch mit Passwort, bitte warten …",
"joiningMessage": "Sie treten der Konferenz bei, sobald jemand Ihre Anfrage annimmt.",
"joiningMessage": "Sie treten der Konferenz bei, sobald jemand Ihre Anfrage annimmt",
"joiningTitle": "Beitritt anfragen …",
"joiningWithPasswordTitle": "Mit Passwort beitreten …",
"knockButton": "Beitritt anfragen",
@@ -816,7 +821,7 @@
"audioUnmuteBlockedTitle": "Stummschaltung kann nicht aufgehoben werden!",
"chatMessages": "Chatnachrichten",
"connectedOneMember": "{{name}} nimmt an der Konferenz teil",
"connectedThreePlusMembers": "{{name}} und {{count}} andere Personen nehmen an der Konferenz teil",
"connectedThreePlusMembers": "{{name}} und viele andere Personen nehmen an der Konferenz teil",
"connectedTwoMembers": "{{first}} und {{second}} nehmen an der Konferenz teil",
"connectionFailed": "Verbindung fehlgeschlagen. Bitte versuchen Sie es später noch einmal!",
"dataChannelClosed": "Schlechte Videoqualität",
@@ -834,7 +839,7 @@
"focusFail": "{{component}} ist im Moment nicht verfügbar wiederholen in {{ms}} Sekunden",
"gifsMenu": "GIPHY",
"groupTitle": "Benachrichtigungen",
"hostAskedUnmute": "Die Moderation bittet Sie, das Mikrofon zu aktivieren",
"hostAskedUnmute": "Die Moderation bittet Sie, das Mikrofon zu aktivieren.",
"invalidTenant": "Ungültiger Mandant",
"invalidTenantHyphenDescription": "Der gewählte Mandantenname ist ungültig (beginnt oder endet mit '-').",
"invalidTenantLengthDescription": "Der gewählte Mandantenname ist zu lang.",
@@ -855,21 +860,21 @@
"localRecordingStarted": "{{name}} hat eine lokale Aufzeichnung gestartet.",
"localRecordingStopped": "{{name}} hat eine lokale Aufzeichnung gestoppt.",
"me": "Ich",
"moderationInEffectCSDescription": "Bitte melden um ein Video zu teilen",
"moderationInEffectCSDescription": "Bitte melden um ein Video zu teilen.",
"moderationInEffectCSTitle": "Die Videofreigabe ist von der Moderation gesperrt",
"moderationInEffectDescription": "Bitte melden um zu sprechen",
"moderationInEffectDescription": "Bitte melden um zu sprechen.",
"moderationInEffectTitle": "Das Mikrofon ist von der Moderation gesperrt",
"moderationInEffectVideoDescription": "Bitte melden um die Kamera zu starten",
"moderationInEffectVideoDescription": "Bitte melden um die Kamera zu starten.",
"moderationInEffectVideoTitle": "Die Kamera ist von der Moderation gesperrt",
"moderationRequestFromModerator": "Die Moderation bittet Sie, das Mikrofon zu aktivieren",
"moderationRequestFromParticipant": "möchte sprechen",
"moderationStartedTitle": "Moderation gestartet",
"moderationStoppedTitle": "Moderation gestoppt",
"moderationToggleDescription": "von {{participantDisplayName}}",
"moderator": "Moderationsrechte vergeben!",
"moderator": "Sie sind jetzt Teil der Moderation",
"muted": "Der Konferenz wurde stumm beigetreten.",
"mutedRemotelyDescription": "Sie können jederzeit die Stummschaltung aufheben, wenn Sie bereit sind zu sprechen. Wenn Sie fertig sind, können Sie sich wieder stummschalten, um Geräusche von der Konferenz fernzuhalten.",
"mutedRemotelyTitle": "Sie wurden von {{participantDisplayName}} stummgeschaltet!",
"mutedRemotelyTitle": "Sie wurden von {{participantDisplayName}} stummgeschaltet",
"mutedTitle": "Stummschaltung aktiv!",
"newDeviceAction": "Verwenden",
"newDeviceAudioTitle": "Neues Audiogerät erkannt",
@@ -880,26 +885,26 @@
"noiseSuppressionStereoDescription": "Rauschunterdrückung unterstützt aktuell keinen Stereoton.",
"oldElectronClientDescription1": "Sie scheinen eine alte Version des Jitsi-Meet-Clients zu nutzen. Diese hat bekannte Schwachstellen. Bitte aktualisieren Sie auf unsere ",
"oldElectronClientDescription2": "aktuelle Version",
"oldElectronClientDescription3": "!",
"oldElectronClientDescription3": " jetzt!",
"openChat": "Chat öffnen",
"participantWantsToJoin": "Möchte an der Konferenz teilnehmen",
"participantsWantToJoin": "Möchten an der Konferenz teilnehmen",
"passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) von einer anderen Person entfernt",
"passwordSetRemotely": "$t(lockRoomPasswordUppercase) von einer anderen Person gesetzt",
"raiseHandAction": "Melden",
"raisedHand": "{{name}} möchte sprechen.",
"raisedHand": "möchte sprechen.",
"raisedHands": "{{participantName}} und {{raisedHands}} weitere möchten sprechen",
"reactionSounds": "Interaktionstöne deaktivieren",
"reactionSoundsForAll": "Interaktionstöne für alle deaktivieren",
"screenShareNoAudio": "Die Option \"Audio freigeben\" wurde bei der Auswahl des Fensters nicht ausgewählt.",
"screenShareNoAudioTitle": "Share audio was not checked",
"screenShareNoAudioTitle": "Systemton konnte nicht geteilt werden!",
"screenSharingAudioOnlyDescription": "Durch die Bildschirmfreigabe wird der Modus \"Beste Leistung\" beeinflusst und daher mehr Datenrate benötigt.",
"screenSharingAudioOnlyTitle": "Modus \"Beste Leistung\"",
"selfViewTitle": "Sie können die eigene Ansicht immer in den Einstellungen reaktivieren",
"somebody": "Jemand",
"startSilentDescription": "Treten Sie der Konferenz noch einmal bei, um Ihr Audio zu aktivieren",
"startSilentTitle": "Sie sind ohne Audioausgabe beigetreten!",
"suboptimalBrowserWarning": "Tut uns leid, aber die Konferenz wird mit {{appName}} kein großartiges Erlebnis. Wir versuchen immer die Situation zu verbessern, bis dahin empfehlen wir aber die Verwendung einer der <a href=\"{{recommendedBrowserPageLink}}\" target=\"_blank\">vollständig unterstützen Browser</a>.",
"suboptimalBrowserWarning": "Tut uns leid, aber die Konferenz wird kein großartiges Erlebnis. Wir versuchen immer die Situation zu verbessern, bis dahin empfehlen wir aber die Verwendung einer der <a href=\"{{recommendedBrowserPageLink}}\" target=\"_blank\">vollständig unterstützen Browser</a>.",
"suboptimalExperienceTitle": "Browserwarnung",
"suggestRecordingAction": "Starten",
"suggestRecordingDescription": "Möchten Sie eine Aufzeichnung starten?",
@@ -908,7 +913,7 @@
"unmuteScreen": "Bildschirmfreigabe starten",
"unmuteVideo": "Kamera einschalten",
"videoMutedRemotelyDescription": "Sie können sie jederzeit wieder einschalten.",
"videoMutedRemotelyTitle": "Ihre Kamera wurde von {{participantDisplayName}} ausgeschaltet!",
"videoMutedRemotelyTitle": "Ihre Kamera wurde von {{participantDisplayName}} ausgeschaltet",
"videoUnmuteBlockedDescription": "Die Kamera und Bildschirmfreigabe kann aus Überlastungsschutzgründen temporär nicht eingeschaltet werden.",
"videoUnmuteBlockedTitle": "Kamera und Bildschirmfreigabe kann nicht aktiviert werden!",
"viewLobby": "Lobby ansehen",
@@ -1002,7 +1007,7 @@
},
"results": {
"changeVote": "Antwort ändern",
"empty": "Es gibt bisher keine Umfragen in dieser Konferenz. Sie können hier eine Umfrage starten!",
"empty": "Es gibt bisher keine Umfragen in dieser Konferenz.",
"hideDetailedResults": "Details verbergen",
"showDetailedResults": "Details anzeigen",
"vote": "Vote"
@@ -1011,7 +1016,7 @@
"poweredby": "Betrieben von",
"prejoin": {
"audioAndVideoError": "Audio- und Videofehler:",
"audioDeviceProblem": "Es gibt ein Problem mit Ihrem Audiogerät.",
"audioDeviceProblem": "Es gibt ein Problem mit Ihrem Audiogerät",
"audioOnlyError": "Audiofehler:",
"audioTrackError": "Audiotrack konnte nicht erstellt werden.",
"callMe": "Mich anrufen",
@@ -1022,8 +1027,8 @@
"connection": {
"failed": "Verbindungstest fehlgeschlagen!",
"good": "Ihre Internetverbindung sieht gut aus!",
"nonOptimal": "Ihre Internetverbindung ist nicht optimal.",
"poor": "Sie haben eine schlechte Internetverbindung.",
"nonOptimal": "Ihre Internetverbindung ist nicht optimal",
"poor": "Sie haben eine schlechte Internetverbindung",
"running": "Verbindung wird getestet…"
},
"connectionDetails": {
@@ -1050,7 +1055,7 @@
"errorDialOutDisconnected": "Anruf fehlgeschlagen. Verbindungsabbruch",
"errorDialOutFailed": "Anruf fehlgeschlagen. Anruf fehlgeschlagen",
"errorDialOutStatus": "Fehler beim Abrufen des Anrufstatus",
"errorMissingName": "Bitte geben Sie Ihren Namen ein, um der Konferenz beizutreten.",
"errorMissingName": "Bitte geben Sie Ihren Namen ein, um der Konferenz beizutreten",
"errorNoPermissions": "Sie müssen den Zugriff auf Mikrofon und Kamera erlauben",
"errorStatusCode": "Anruf fehlgeschlagen. Statuscode: {{status}}",
"errorValidation": "Nummerverifikation fehlgeschlagen",
@@ -1062,7 +1067,7 @@
"joinWithoutAudio": "Ohne Ton beitreten",
"keyboardShortcuts": "Tastaturkurzbefehle aktivieren",
"linkCopied": "Link in die Zwischenablage kopiert",
"lookGood": "Alles scheint zu funktionieren.",
"lookGood": "Alles scheint zu funktionieren",
"or": "oder",
"premeeting": "Vorschau",
"proceedAnyway": "Trotzdem fortsetzen",
@@ -1119,7 +1124,7 @@
"error": "Die Aufzeichnung ist fehlgeschlagen. Bitte versuchen Sie es erneut.",
"errorFetchingLink": "Der Link zur Aufzeichnung konnte nicht geladen werden.",
"expandedOff": "Aufzeichnung wurde gestoppt",
"expandedOn": "Die Konferenz wird momentan aufgezeichnet.",
"expandedOn": "Die Konferenz wird momentan aufgezeichnet",
"expandedPending": "Aufzeichnung wird gestartet…",
"failedToStart": "Die Aufnahme konnte nicht gestartet werden",
"fileSharingdescription": "Aufzeichnung mit den Personen der Konferenz teilen",
@@ -1144,9 +1149,11 @@
"offBy": "{{name}} stoppte die Aufnahme",
"on": "Aufnahme",
"onBy": "{{name}} startete die Aufnahme",
"onByWithTranscription": "{{name}} startete die Aufnahme. Ein Transkript wird nach der Konferenz verfügbar sein.",
"onWithTranscription": "Aufnahme gestartet. Ein Transkript wird nach der Konferenz verfügbar sein.",
"onlyRecordSelf": "Nur eigenes Kamerabild und Ton aufzeichnen",
"pending": "Aufzeichnung der Konferenz wird vorbereitet…",
"policyError": "Sie haben die Aufzeichnung zu schnell gestartet. Bitte versuchen Sie es später noch einmal.",
"policyError": "Sie haben die Aufzeichnung zu schnell gestartet. Bitte versuchen Sie es später noch einmal!",
"recordAudioAndVideo": "Kamera und Ton aufzeichnen",
"recordTranscription": "Transkription aufzeichnen",
"saveLocalRecording": "Aufzeichnung lokal abspeichern",
@@ -1169,8 +1176,8 @@
"pullToRefresh": "Ziehen, um zu aktualisieren"
},
"security": {
"about": "Sie können Ihre Konferenz mit einem Passwort sichern. Personen müssen dieses eingeben, bevor sie an der Sitzung teilnehmen dürfen.",
"aboutReadOnly": "Mit Moderationsrechten kann die Konferenz mit einem Passwort gesichert werden. Personen müssen dieses eingeben, bevor sie an der Sitzung teilnehmen dürfen.",
"about": "Sie können Ihre Konferenz mit einem $t(lockRoomPassword) sichern. Personen müssen das $t(lockRoomPassword) eingeben, bevor sie an der Konferenz teilnehmen dürfen.",
"aboutReadOnly": "Mit Moderationsrechten kann die Konferenz mit einem $t(lockRoomPassword) gesichert werden. Personen müssen das $t(lockRoomPassword) eingeben, bevor sie an der Konferenz teilnehmen dürfen.",
"insecureRoomNameWarningNative": "Der Raumname ist unsicher. Unerwünschte Personen könnten Ihrer Konferenz beitreten. {{recommendAction}} Lernen Sie mehr über die Absicherung Ihrer Konferenz ",
"insecureRoomNameWarningWeb": "Der Raumname ist unsicher. Unerwünschte Personen könnten Ihrer Konferenz beitreten {{recommendAction}} Lernen Sie <a href=\"{{securityUrl}}\" rel=\"security\" target=\"_blank\">hier</a> mehr über die Absicherung Ihrer Konferenz.",
"title": "Sicherheitsoptionen",
@@ -1273,9 +1280,9 @@
"displayEmotions": "Emotionen anzeigen",
"fearful": "Ängstlich",
"happy": "Fröhlich",
"hours": "{{count}} Std. ",
"hours": "{{count}} Std.",
"labelTooltip": "Anzahl der Personen: {{count}}",
"minutes": "{{count}} Min. ",
"minutes": "{{count}} Min.",
"name": "Name",
"neutral": "Neutral",
"sad": "Traurig",
@@ -1358,7 +1365,7 @@
"muteEveryoneElse": "Alle anderen stummschalten",
"muteEveryoneElsesVideoStream": "Alle anderen Kameras ausschalten",
"muteEveryonesVideoStream": "Alle Kameras ausschalten",
"muteGUMPending": "Verbinde Ihr Mikrofon",
"muteGUMPending": "Mikrofon verbinden",
"noiseSuppression": "Rauschunterdrückung",
"openChat": "Chat öffnen",
"participants": "Anwesenheitsliste öffnen. {{participantsCount}} anwesend",
@@ -1393,7 +1400,7 @@
"unmute": "Stummschaltung aufheben",
"videoblur": "Unscharfer Hintergrund ein-/ausschalten",
"videomute": "Kamera stoppen",
"videomuteGUMPending": "Verbinde Ihre Kamera",
"videomuteGUMPending": "Kamera verbinden",
"videounmute": "Kamera einschalten"
},
"addPeople": "Personen zur Konferenz hinzufügen",
@@ -1463,7 +1470,7 @@
"mute": "Audio stummschalten",
"muteEveryone": "Alle stummschalten",
"muteEveryonesVideo": "Alle Kameras ausschalten",
"muteGUMPending": "Verbinde Ihre Kamera",
"muteGUMPending": "Mikrofon verbinden",
"noAudioSignalDesc": "Wenn Sie das Gerät nicht absichtlich über die Systemeinstellungen oder die Hardware stumm geschaltet haben, sollten Sie einen Wechsel des Geräts in Erwägung ziehen.",
"noAudioSignalDescSuggestion": "Wenn Sie das Gerät nicht absichtlich über die Systemeinstellungen oder die Hardware stummgeschaltet haben, sollten Sie einen Wechsel auf das vorgeschlagene Gerät in Erwägung ziehen.",
"noAudioSignalDialInDesc": "Sie können sich auch über die Einwahlnummer einwählen:",
@@ -1500,7 +1507,7 @@
"silence": "Stille",
"speakerStats": "Sprechstatistik",
"startScreenSharing": "Bildschirmfreigabe starten",
"startSubtitles": "Untertitel einschalten",
"startSubtitles": "Untertitel • {{language}}",
"stopAudioSharing": "Audiofreigabe stoppen",
"stopScreenSharing": "Bildschirmfreigabe stoppen",
"stopSharedVideo": "Video stoppen",
@@ -1512,15 +1519,19 @@
"unmute": "Stummschaltung aufheben",
"videoSettings": "Kamera-Einstellungen",
"videomute": "Kamera stoppen",
"videomuteGUMPending": "Verbinde Ihre Kamera",
"videomuteGUMPending": "Kamera verbinden",
"videounmute": "Kamera einschalten"
},
"transcribing": {
"ccButtonTooltip": "Untertitel ein-/ausschalten",
"expandedLabel": "Transkribieren ist derzeit eingeschaltet",
"failed": "Transkribieren fehlgeschlagen",
"labelTooltip": "Die Konferenz wird transkribiert",
"labelTooltip": "Die Konferenz wird transkribiert.",
"labelTooltipExtra": "Zusätzlich wird das Transkript später verfügbar sein.",
"off": "Transkription gestoppt",
"on": "Transkription gestartet",
"onBy": "{{name}} startete die Transkription",
"onWithRecording": "Ein Transkript wird nach der Konferenz verfügbar sein.",
"openClosedCaptions": "Untertitel öffnen",
"original": "Original",
"sourceLanguageDesc": "Aktuell ist die Sprache der Konferenz auf <b>{{sourceLanguage}}</b> eingestellt. <br/> Sie könne dies hier ",
@@ -1562,7 +1573,7 @@
"ldTooltip": "Video wird in niedriger Auflösung angezeigt",
"lowDefinition": "Niedrige Auflösung",
"performanceSettings": "Qualitätseinstellungen",
"recording": "Aufnahme läuft",
"recording": "Aufnahme läuft.",
"sd": "SD",
"sdTooltip": "Video wird in Standardauflösung angezeigt",
"standardDefinition": "Standardauflösung",
@@ -1603,6 +1614,7 @@
"addBackground": "Hintergrund hinzufügen",
"apply": "Anwenden",
"backgroundEffectError": "Hintergrund konnte nicht aktiviert werden.",
"backgroundLimitReached": "Maximale Zahl an Hintergründen erreicht",
"blur": "Hintergrund unscharf",
"deleteImage": "Bild löschen",
"desktopShare": "Desktopfreigabe",
@@ -1612,9 +1624,10 @@
"image3": "Weißer leerer Raum",
"image4": "Schwarze Stehlampe",
"image5": "Berg",
"image6": "Wald",
"image6": "Wald ",
"image7": "Sonnenaufgang",
"none": "keiner",
"oldestBackgroundRemoved": "Der älteste Hintergrund wurde gelöscht, um den Neuesten hinzuzufügen.",
"pleaseWait": "Bitte warten…",
"removeBackground": "Hintergrund entfernen",
"slightBlur": "Hintergrund leicht unscharf",
@@ -1629,7 +1642,7 @@
"description": "Sie beobachten derzeit diese Konferenz.",
"raiseHand": "Hand heben",
"title": "Konferenz wird beigetreten",
"wishToSpeak": "Wenn Sie sprechen möchten, heben Sie bitte unten Ihre Hand und warten Sie auf die Zustimmung der Moderation"
"wishToSpeak": "Wenn Sie sprechen möchten, heben Sie bitte unten Ihre Hand und warten Sie auf die Zustimmung der Moderation."
},
"labelTooltip": "Anzahl Gäste: {{count}}",
"notification": {
@@ -1639,7 +1652,7 @@
"noVisitorLobby": "Sie können nicht teilnehmen, solange die Lobby für diese Konferenz aktiviert ist.",
"notAllowedPromotion": "Eine Person muss Ihre Anfrage erst erlauben.",
"requestToJoin": "Hand gehoben",
"requestToJoinDescription": "Ihre Anfrage wurde an die Moderation gesendet, bitte warten Sie.",
"requestToJoinDescription": "Ihre Anfrage wurde an die Moderation gesendet, bitte warten Sie!",
"title": "Sie sind Gast in der Konferenz"
},
"waitingMessage": "Sie werden der Konferenz beitreten, sobald sie gestartet ist!"

View File

@@ -1494,6 +1494,10 @@
"failed": "La transcription a échoué",
"labelTooltip": "La transcription de la réunion est en cours",
"labelTooltipExtra": "De plus, une transcription sera disponible plus tard.",
"off": "Transcription arrêtée",
"on": "Transcription démarrée",
"onBy": "{{name}} a démarré la transcription",
"onWithRecording": "Une transcription sera également disponible après la réunion.",
"openClosedCaptions": "Ouvrir les sous-titres",
"original": "Original",
"sourceLanguageDesc": "Actuellement, la langue de la réunion est sélectionnée à <b>{{sourceLanguage}}</b>. <br/> Vous pouvez la changer à partir de ",

View File

@@ -1518,6 +1518,10 @@
"failed": "La transcription a échoué",
"labelTooltip": "La transcription de la réunion est en cours",
"labelTooltipExtra": "Une transcription sera disponible plus tard.",
"off": "Transcription arrêtée",
"on": "Transcription démarrée",
"onBy": "{{name}} a démarré la transcription",
"onWithRecording": "Une transcription sera également disponible après la réunion.",
"openClosedCaptions": "Ouvrir les sous-titres",
"original": "Original",
"sourceLanguageDesc": "Actuellement, la langue de la réunion est sélectionnée à <b>{{sourceLanguage}}</b>. <br/> Vous pouvez la changer à partir de ",

View File

@@ -871,6 +871,7 @@
"or": "lub",
"premeeting": "Przed spotkaniem",
"proceedAnyway": "Kontynuuj mimo to",
"recordingWarning": "Inni uczestnicy mogą nagrywać tę rozmowę",
"screenSharingError": "Błąd udostępniania ekranu:",
"startWithPhone": "Uruchom przez telefon",
"unsafeRoomConsent": "Rozumiem ryzyko, chcę dołączyć do spotkania",

View File

@@ -114,6 +114,9 @@
"error": "Fel: ditt meddelande skickades inte. Orsak: {{error}}",
"everyone": "Alla",
"fieldPlaceHolder": "Skriv ditt meddelande här",
"fileAccessibleTitle": "{{user}} laddade upp en fil",
"fileAccessibleTitleMe": "jag laddade upp en fil",
"fileDeleted": "En fil raderades",
"guestsChatIndicator": "(gäst)",
"lobbyChatMessageTo": "Skicka meddelande",
"message": "Meddelande",
@@ -123,8 +126,16 @@
"messagebox": "Skriv ett meddelande",
"newMessages": "Nytt meddelande",
"nickname": {
"featureChat": "chatt",
"featureClosedCaptions": "textning",
"featureFileSharing": "fildelning",
"featurePolls": "omröstningar",
"popover": "Välj ett namn",
"title": "Skriv in ett namn för att börja använda chatten",
"titleWith1Features": "Ange ett smeknamn för att använda {{feature1}}",
"titleWith2Features": "Ange ett smeknamn för att använda {{feature1}} och {{feature2}}",
"titleWith3Features": "Ange ett smeknamn för att använda {{feature1}}, {{feature2}} och {{feature3}}",
"titleWith4Features": "Ange ett smeknamn för att använda {{feature1}}, {{feature2}}, {{feature3}} och {{feature4}}",
"titleWithCC": "Skriv in ett namn för att börja använda chatten och för undertexter",
"titleWithPolls": "Skriv in ett namn för att börja använda chatten och omröstningar",
"titleWithPollsAndCC": "Skriv in ett namn för att börja använda chatten, omröstningar och undertexter",
@@ -216,6 +227,9 @@
"video_ssrc": "Video SSRC:",
"yes": "Ja"
},
"customPanel": {
"close": "Stäng"
},
"dateUtils": {
"earlier": "Tidigare",
"today": "Idag",
@@ -230,10 +244,10 @@
"downloadMobileApp": "Ladda ner mobilappen",
"ifDoNotHaveApp": "Om du inte har appen än:",
"ifHaveApp": "Om du redan har appen:",
"joinInApp": "Delta i detta möte med din app",
"joinInApp": "Delta i det här mötet med din app",
"joinInAppNew": "Delta i appen",
"joinInBrowser": "Delta på webben",
"launchMeetingLabel": "Hur vill du delta i detta möte?",
"launchMeetingLabel": "Hur vill du delta i det här mötet?",
"launchWebButton": "Starta på webben",
"noDesktopApp": "",
"noMobileApp": "Har du inte appen?",
@@ -280,7 +294,7 @@
"Submit": "Skicka",
"Understand": "Jag förstår, låt min mikrofon vara avstängd tillsvidare",
"UnderstandAndUnmute": "Jag förstår, starta min mikrofon",
"WaitForHostNoAuthMsg": "Konferensen har ännu inte startat eftersom ingen värd har anlänt ännu. Vänligen vänta.",
"WaitForHostNoAuthMsg": "Konferensen har ännu inte startat eftersom ingen värd har anlänt ännu. Var god vänta.",
"WaitingForHostButton": "Vänta på värd",
"WaitingForHostTitle": "Väntar på värden…",
"Yes": "Ja",
@@ -330,7 +344,7 @@
"contactSupport": "Kontakta kundtjänst",
"copied": "Kopierad",
"copy": "Kopiera",
"demoteParticipantDialog": "Are you sure you want to move this participant to viewer? Är du säker på att du vill flytta denna deltagaren till tittare",
"demoteParticipantDialog": "Är du säker på att du vill flytta den här deltagaren till tittare?",
"demoteParticipantTitle": "Flytta till tittare",
"dismiss": "Förkasta",
"displayNameRequired": "Hej, vad heter du?",
@@ -344,18 +358,18 @@
"enterDisplayName": "Ange namn",
"error": "Fel",
"errorRoomCreationRestriction": "Du försökte gå med för snabbt, kom tillbaka om en stund.",
"gracefulShutdown": "Vår tjänst är för tillfället nedstängd för underhåll. Vänligen försök senare.",
"grantModeratorDialog": "Är du säker du vill göra denna deltagare till en moderator?",
"gracefulShutdown": "Vår tjänst är för tillfället nedstängd för underhåll. Försök senare.",
"grantModeratorDialog": "Är du säker på att du vill göra den här deltagaren till moderator?",
"grantModeratorTitle": "Godkänn moderator",
"hide": "Dölj",
"hideShareAudioHelper": "Visa inte denna dialog igen ",
"hideShareAudioHelper": "Visa inte den här dialogen igen ",
"incorrectPassword": "Fel användarnamn eller lösenord",
"incorrectRoomLockPassword": "Felaktigt lösenord",
"internalError": "Ett fel uppstod. Fel: {{error}}",
"internalErrorTitle": "Internt fel",
"kickMessage": "Du kan kontakta {{participantDisplayName}} för mer information.",
"kickParticipantButton": "Ta bort deltagaren från mötet",
"kickParticipantDialog": "Vill du ta bort denna deltagaren från mötet?",
"kickParticipantDialog": "Vill du ta bort den här deltagaren från mötet?",
"kickParticipantTitle": "Tysta deltagaren?",
"kickSystemTitle": "Du har blivit borttagen från mötet",
"kickTitle": "{{participantDisplayName}} tog bort dig från mötet",
@@ -369,6 +383,9 @@
"lockRoom": "Lägg till möte $t(lockRoomPasswordUppercase)",
"lockTitle": "Låsning misslyckades",
"login": "Logga in",
"loginFailed": "Inloggningen misslyckades.",
"loginOnResume": "Din autentiseringssession har gått ut. Du måste logga in igen för att fortsätta mötet.",
"loginPopupBlocked": "Inloggningsfönstret blockerades av din webbläsare.",
"loginQuestion": "Är du säker på att du vill logga in och lämna mötet",
"logoutQuestion": "Är du säker på att du vill logga ut och lämna konferensen?",
"logoutTitle": "Logga ut",
@@ -413,18 +430,18 @@
"muteParticipantsVideoBody": "Du kommer inte att kunna aktivera kameran igen. Däremot kan deltagaren kunna aktivera sin egen kamera när som.",
"muteParticipantsVideoBodyModerationOn": "Du och deltagarna kommer inte att kunna aktivera kameran igen.",
"muteParticipantsVideoButton": "Inaktivera kamera",
"muteParticipantsVideoDialog": "Är du säker du vill inaktivera denna deltagares kamera. Du kommer inte att kunna aktivera den igen. Däremot kan deltagaren kunna aktivera sin egen kamera när som.",
"muteParticipantsVideoDialog": "Är du säker du vill inaktivera den här deltagarens kamera. Du kommer inte att kunna aktivera den igen. Däremot kan deltagaren kunna aktivera sin egen kamera när som.",
"muteParticipantsVideoDialogModerationOn": "Är du säker på att du vill inaktivera den här deltagarens kamera? Du kommer inte att kunna aktivera kameran igen och inte de heller.",
"muteParticipantsVideoTitle": "Inaktivera denna deltagares kamera?",
"muteParticipantsVideoTitle": "Inaktivera den här deltagarens kamera?",
"noDropboxToken": "Ingen giltig dropbox tecken",
"password": "Lösenord",
"passwordLabel": "Mötet har låsts av en deltagare. Ange $t(lockRoomPassword) för att gå med.",
"passwordNotSupported": "Att sätta ett $t(lockRoomPassword) för mötesrummet stöds ej.",
"passwordNotSupportedTitle": "$t(lockRoomPasswordUppercase) stöds inte",
"passwordRequired": "$t(lockRoomPasswordUppercase) krävs",
"permissionCameraRequiredError": "Tillåtelse krävs för att delta med kamera i denna möte. Var god skaffa detta i \"inställningar\".",
"permissionCameraRequiredError": "Behörighet krävs för att delta med kamera i det här mötet. Ändra detta i \"Inställningar\".",
"permissionErrorTitle": "Tillåtelse krävs",
"permissionMicRequiredError": "Tillåtelse krävs för att delta med mikrofon i denna möte. Var god skaffa detta i \"inställningar\".",
"permissionMicRequiredError": "Behörighet krävs för att delta med mikrofon i det här mötet. Ändra detta i \"Inställningar\".",
"readMore": "Mer",
"recentlyUsedObjects": "Dina senaste använda objekt",
"recording": "Inspelning",
@@ -452,12 +469,14 @@
"screenSharingFailed": "Oops! Något gick fel, skärmdelning kunde ej startas.",
"screenSharingFailedTitle": "Skärmdelning misslyckades!",
"screenSharingPermissionDeniedError": "Något är fel med åtkomstinställningarna för skärmdelningen. Ladda om sidan och försök igen.",
"screenshareStoppedDiskSpace": "Det här händer om du använde macOS flytande verktygsfält för att stoppa skärmdelningen. Det har inte stöd för att starta den igen.",
"screenshareStoppedTitle": "Skärmdelningen stoppades via systemet",
"searchInSalesforce": "Sök i Salesforce",
"searchResults": "Sökresultat ({{count}})",
"searchResultsDetailsError": "Något gick fel när ägardata hämtades.",
"searchResultsError": "Något gick fel när data hämtades.",
"searchResultsNotFound": "Inga sökresultat hittades.",
"searchResultsTryAgain": "Try using alternative keywords.",
"searchResultsTryAgain": "Försök med andra sökord.",
"sendPrivateMessage": "Du har fått ett privat meddelande. Tänkte du svara på det privat, eller vill du skicka ditt meddelande till alla deltagare?",
"sendPrivateMessageCancel": "Skicka till alla deltagare",
"sendPrivateMessageOk": "Skicka privat",
@@ -520,7 +539,7 @@
"tokenAuthFailedWithReasons": "Förlåt, du har inte tillåtelse att gå med i det här samtalet. Troliga anledingar: {{reason}}",
"tokenAuthUnsupported": "Token URL är inte tillåten",
"transcribing": "Transkriberar",
"unauthenticatedAccessDisabled": "Detta samtalet kräver identifiering. Logga in för att fortsätta.",
"unauthenticatedAccessDisabled": "Det här samtalet kräver identifiering. Logga in för att fortsätta.",
"unlockRoom": "Ta bort möte $t(lockRoomPassword)",
"user": "Användare",
"userIdentifier": "Användar-ID",
@@ -543,7 +562,7 @@
"title": "Delade dokument"
},
"e2ee": {
"labelToolTip": "Ljud- och videokommunikation för detta samtal är krypterad från dator till dator"
"labelToolTip": "Ljud- och videokommunikation för det här samtalet är krypterad från dator till dator"
},
"embedMeeting": {
"title": "Bädda in möte"
@@ -576,6 +595,7 @@
"newFileNotification": "{{ participantName }} delade '{{ fileName }}'",
"removeFile": "Ta bort",
"removeFileSuccess": "Filen togs bort",
"uploadDisabled": "Inte tillåtet att ladda upp filer. Be en moderator om behörighet för den åtgärden.",
"uploadFailedDescription": "Snälla försök igen.",
"uploadFailedTitle": "Överföring misslyckades",
"uploadFile": "Dela fil"
@@ -603,7 +623,7 @@
"conferenceURL": "Länk:",
"copyNumber": "Kopiera nummer",
"country": "Land",
"dialANumber": "Om du vill gå med i mötet ringer du något av dessa nummer och fyller sedan i PIN-koden.",
"dialANumber": "Om du vill gå med i mötet ringer du något av de här numren och fyller sedan i PIN-koden.",
"dialInConferenceID": "PIN-kod:",
"dialInNotSupported": "Tyvärr stöds inte inringning just nu.",
"dialInNumber": "Inringning:",
@@ -635,14 +655,14 @@
"sipAudioOnly": "SIP endast ljud address",
"title": "Dela",
"tooltip": "Dela länk och information om inringning för mötet",
"upgradeOptions": "Vänligen kontrollera om uppgraderingsalternativen är på",
"upgradeOptions": "Kontrollera om uppgraderingsalternativen är på",
"whiteboardError": "Problem att ladda whiteboard, var god försök senare."
},
"inlineDialogFailure": {
"msg": "Vi slirade lite.",
"retry": "Försök igen",
"support": "Support",
"supportMsg": "Om detta fortsätter hända kontakta"
"supportMsg": "Om det här fortsätter att hända, kontakta"
},
"inviteDialog": {
"alertText": "Det gick inte att bjuda in alla deltagare.",
@@ -709,13 +729,14 @@
"streamIdHelp": "Vad är det här?",
"title": "Direktsändning",
"unavailableTitle": "Livesändning otillgänglig",
"youTubeGoLiveWarning": "Kom ihåg att klicka på \"Gå live\" i YouTube Studio om autostart/autostopp är inaktiverade.",
"youtubeTerms": "Tjänstevillkor för YouTube"
},
"lobby": {
"backToKnockModeButton": "Tillbaka till väntrum",
"chat": "Chatt",
"dialogTitle": "Väntrum",
"disableDialogContent": "Väntrumsläge är för närvarande aktiverat. Denna funktion säkerställer att oönskade deltagare inte kan gå med i ditt möte. Vill du inaktivera det?",
"disableDialogContent": "Väntrumsläge är för närvarande aktiverat. Den här funktionen säkerställer att oönskade deltagare inte kan gå med i ditt möte. Vill du inaktivera det?",
"disableDialogSubmit": "Inaktivera",
"emailField": "Skriv in din mailadress",
"enableDialogPasswordField": "Ange lösenord (valfritt)",
@@ -809,7 +830,7 @@
"desktopMutedRemotelyTitle": "Din skärmdelning har avslutats av {{participantDisplayName}}",
"disabledIframe": "Inbäddning är endast avsedd för demonstrationsändamål, så det här samtalet kommer att kopplas ner om {{timeout}} minuter.",
"disabledIframeSecondaryNative": "Inbäddning {{domain}} är endast avsedd för demonstrationsändamål, så det här samtalet kommer att kopplas ner om {{timeout}} minuter.",
"disabledIframeSecondaryWeb": "Bädda in {{domain}} är bara till för demo, så detta samtal kommer att kopplas bort inom {{timeout}} minuter. Var god använd <a href='{{jaasDomain}}' rel='nooper noreferrer' target='_blank'>Jitsi som tjänst</a> för att bädda in i produktion.",
"disabledIframeSecondaryWeb": "Bädda in {{domain}} är bara till för demo, så det här samtalet kommer att kopplas bort inom {{timeout}} minuter. Var god använd <a href='{{jaasDomain}}' rel='nooper noreferrer' target='_blank'>Jitsi som tjänst</a> för att bädda in i produktion.",
"disconnected": "frånkopplad",
"displayNotifications": "Visa aviseringar för",
"dontRemindMe": "Påminn mig inte",
@@ -832,7 +853,7 @@
"linkToSalesforce": "Länk till Salesforce",
"linkToSalesforceDescription": "Du kan länka mötessammanfattningen till ett Salesforce-objekt.",
"linkToSalesforceError": "Det gick inte att länka mötet till Salesforce",
"linkToSalesforceKey": "Länka detta möte",
"linkToSalesforceKey": "Länka det här mötet",
"linkToSalesforceProgress": "Länkar möte till Salesforce…",
"linkToSalesforceSuccess": "Mötet länkades till Salesforce",
"localRecordingStarted": "{{name}} har påbörjat en lokal inspelning.",
@@ -858,7 +879,7 @@
"newDeviceAudioTitle": "Ny ljudenhet hittad",
"newDeviceCameraTitle": "Ny kamera hittad",
"nextToSpeak": "Du är näst i kö för att prata",
"noiseSuppressionDesktopAudioDescription": "Brusreducering kan inte aktiveras när du delar skrivbordsljud, vänligen inaktivera det och försök igen.",
"noiseSuppressionDesktopAudioDescription": "Brusreducering kan inte aktiveras när du delar skrivbordsljud, inaktivera det och försök igen.",
"noiseSuppressionFailedTitle": "Det gick inte att starta brusreducering",
"noiseSuppressionStereoDescription": "Brusreducering i stereoljud stöds för närvarande inte.",
"oldElectronClientDescription1": "Den version av Jitsi meet som används är gammal och har säkerhetsluckor. Var god uppdatera till den senaste versionen.",
@@ -886,7 +907,7 @@
"suboptimalExperienceTitle": "Webbläsarvarning",
"suggestRecordingAction": "Starta",
"suggestRecordingDescription": "Vill du starta en inspelning?",
"suggestRecordingTitle": "Spela in detta mötet",
"suggestRecordingTitle": "Spela in det här mötett",
"unmute": "Slå på mikrofonen",
"unmuteScreen": "Starta skärmdelning",
"unmuteVideo": "Starta kamera",
@@ -981,7 +1002,7 @@
},
"notification": {
"description": "Öppna fliken omröstningar för att rösta",
"title": "En ny omröstning har blivit tillagd till detta möte"
"title": "En ny omröstning har blivit tillagd till det här mötet"
},
"results": {
"changeVote": "Ändra din röst",
@@ -1014,7 +1035,7 @@
"audioHighQuality": "Vi förväntar oss att ditt ljud har utmärkt kvalitet.",
"audioLowNoVideo": "Vi förväntar oss att din ljudkvalitet är låg och ingen video.",
"goodQuality": "Grymt bra! Din mediekvalitet kommer att bli bra.",
"noMediaConnectivity": "Vi kunde inte hitta ett sätt att upprätta mediaanslutning för detta test. Detta orsakas vanligtvis av en brandvägg eller NAT.",
"noMediaConnectivity": "Vi kunde inte upprätta mediaanslutning för det här testet. Det orsakas vanligtvis av en brandvägg eller NAT.",
"noVideo": "Vi förväntar oss att din video kommer ha låg kvalitet eller inte fungera.",
"testFailed": "Anslutningstestet stötte på oväntade problem, men det behöver inte påverka din upplevelse.",
"undetectable": "Om du fortfarande inte kan ringa i webbläsaren rekommenderar vi att du ser till att dina högtalare, mikrofon och kamera är korrekt inställda, att du har beviljat din webbläsare rättigheter att använda din mikrofon och kamera och att din webbläsarversion är uppdaterad.",
@@ -1028,7 +1049,7 @@
"dialInMeeting": "Ring in till mötet",
"dialInPin": "Ring in till mötet och ange PIN-kod:",
"dialing": "Ringer",
"doNotShow": "Visa inte denna ruta igen",
"doNotShow": "Visa inte den här rutan igen",
"errorDialOut": "Kunde inte ringa ut",
"errorDialOutDisconnected": "Kunde inte ringa ut. Kopplar ner",
"errorDialOutFailed": "Kunde inte ringa ut. Samtal misslyckades",
@@ -1082,7 +1103,7 @@
"raisedHandsLabel": "Antal uppräckta händer",
"record": {
"already": {
"linked": "Mötet är redan länkat till detta Salesforce-objekt."
"linked": "Mötet är redan länkat till det här Salesforce-objekt."
},
"type": {
"account": "Konto",
@@ -1121,7 +1142,7 @@
"localRecordingVideoWarning": "För att spela in din video måste du ha den på när du startar inspelningen",
"localRecordingWarning": "Se till att du väljer den aktuella fliken för att kunna använda rätt video och ljud.",
"loggedIn": "Inloggad som {{userName}}",
"noMicPermission": "Mikrofonspåret kunde inte skapas. Vänligen ge tillstånd att använda mikrofonen.",
"noMicPermission": "Mikrofonspåret kunde inte skapas. Ge tillstånd att använda mikrofonen.",
"noStreams": "Ingen ljud- eller videoström upptäcktes.",
"off": "Inspelningen avslutades",
"offBy": "{{name}} avslutade inspelningen",
@@ -1246,7 +1267,7 @@
"version": "Version"
},
"share": {
"dialInfoText": "\n\n=====\n\nVill du istället ringa in via telefon?\n\n{{defaultDialInNumber}} Klicka på den här länken för att se telefonnumret för detta möte\n{{dialInfoPageUrl}}",
"dialInfoText": "\n\n=====\n\nVill du istället ringa in via telefon?\n\n{{defaultDialInNumber}} Klicka på den här länken för att se telefonnumret för det här mötet\n{{dialInfoPageUrl}}",
"mainText": "Klicka på länken för att delta i mötet:\n{{roomUrl}}"
},
"speaker": "Talare",
@@ -1298,6 +1319,7 @@
"chat": "Öppna eller stäng chattfönster",
"clap": "Applådera",
"closeChat": "Stäng chatten",
"closeCustomPanel": "Stäng",
"closeMoreActions": "Stäng menyn för fler åtgärder",
"closeParticipantsPane": "Stäng deltagarfönstret",
"closedCaptions": "Undertexter",
@@ -1403,9 +1425,11 @@
"chat": "Öppna / stäng chatten",
"clap": "Klappa",
"closeChat": "Stäng chatt",
"closeCustomPanel": "Stäng",
"closeParticipantsPane": "Stäng deltagarrutan",
"closeReactionsMenu": "Stäng meny för reaktioner",
"closedCaptions": "Undertexter",
"copilot": "Copilot",
"disableNoiseSuppression": "Inaktivera brusreducering",
"disableReactionSounds": "Du kan inaktivera reaktionsljud för det här mötet",
"documentClose": "Stäng delat dokument",
@@ -1420,6 +1444,7 @@
"exitFullScreen": "Stäng fullskärm",
"exitTileView": "Stäng panelvy",
"feedback": "Lämna återkoppling",
"fileSharing": "Fildelning",
"giphy": "Växla GIPHY-menyn",
"hangup": "Lämna",
"help": "Hjälp",
@@ -1455,6 +1480,7 @@
"openReactionsMenu": "Öppna meny för reaktioner",
"participants": "Deltagare",
"pip": "Öppna bild-i-bild-läge",
"polls": "Omröstningar",
"privateMessage": "Skicka privat meddelande",
"profile": "Redigera din profil",
"raiseHand": "Räck upp / ta ner din hand",
@@ -1581,6 +1607,7 @@
"addBackground": "Lägg till bakgrund",
"apply": "Tillämpa",
"backgroundEffectError": "Det gick inte att tillämpa bakgrundseffekt.",
"backgroundLimitReached": "Gränsen för anpassade bakgrunder har nåtts",
"blur": "Oskärpa",
"deleteImage": "Ta bort bild",
"desktopShare": "Dela skrivbord",
@@ -1593,7 +1620,8 @@
"image6": "Skog",
"image7": "Soluppgång",
"none": "Ingen",
"pleaseWait": "Vänligen vänta…",
"oldestBackgroundRemoved": "Den äldsta anpassade bakgrunden har tagits bort för att lägga till den nya.",
"pleaseWait": "Var god vänta…",
"removeBackground": "Ta bort bakgrunden",
"slightBlur": "Lätt oskärpa",
"title": "Virtuella bakgrunder",
@@ -1665,7 +1693,7 @@
"recentListEmpty": "Inga tidigare möten. Chatta med ditt team och hitta alla tidigare möten där.",
"recentMeetings": "Dina senaste möten",
"reducedUIText": "Välkommen till {{app}}!",
"roomNameAllowedChars": "Mötesnamn kan inte innehålla dessa tecken: ?, &,:, ', \",%, #.",
"roomNameAllowedChars": "Mötesnamn kan inte innehålla de här tecknen: ?, &,:, ', \",%, #.",
"roomname": "Skriv in rumsnamn",
"roomnameHint": "Ange namnet eller URL:en till mötesrummet du vill ansluta till. Du kan hitta på ett nytt namn, berätta då för de andra du tänker möta så de anger samma namn.",
"sendFeedback": "Ge återkoppling",

View File

@@ -701,6 +701,7 @@
"changeSignIn": "Switch accounts.",
"choose": "Choose a live stream",
"chooseCTA": "Choose a streaming option. You're currently logged in as {{email}}.",
"chooseCTAWithChangeSignIn": "Choose a streaming option. You're currently logged in as {{email}}. <0>Switch accounts.</0>",
"enterStreamKey": "Enter your YouTube live stream key here.",
"error": "Live Streaming failed. Please try again.",
"errorAPI": "An error occurred while accessing your YouTube broadcasts. Please try logging in again.",
@@ -1148,6 +1149,8 @@
"offBy": "{{name}} stopped the recording",
"on": "Recording started",
"onBy": "{{name}} started the recording",
"onByWithTranscription": "{{name}} started the recording. A transcript will also be available after the meeting.",
"onWithTranscription": "Recording started. A transcript will also be available after the meeting.",
"onlyRecordSelf": "Record only my audio and video streams",
"pending": "Preparing to record the meeting…",
"policyError": "You tried to start a recording too quickly. Please try again later!",
@@ -1525,6 +1528,10 @@
"failed": "Transcribing failed",
"labelTooltip": "This meeting is being transcribed.",
"labelTooltipExtra": "In addition, a transcript will be available later.",
"off": "Transcription stopped",
"on": "Transcription started",
"onBy": "{{name}} started the transcription",
"onWithRecording": "A transcript will also be available after the meeting.",
"openClosedCaptions": "Open closed captions",
"original": "Original",
"sourceLanguageDesc": "Currently the meeting language is set to <b>{{sourceLanguage}}</b>. <br/> You can change it from ",
@@ -1624,6 +1631,10 @@
"pleaseWait": "Please wait…",
"removeBackground": "Remove background",
"slightBlur": "Half Blur",
"studioLightNatural": "Natural",
"studioLightSoftFocus": "Soft Focus",
"studioLightSpotlight": "Spotlight",
"studioLightTitle": "Studio Light",
"title": "Virtual backgrounds",
"uploadedImage": "Uploaded image {{index}}",
"webAssemblyWarning": "WebAssembly not supported",

View File

@@ -128,7 +128,11 @@ import { isAudioMuteButtonDisabled } from '../../react/features/toolbox/function
import { setTileView, toggleTileView } from '../../react/features/video-layout/actions.any';
import { muteAllParticipants, muteRemote } from '../../react/features/video-menu/actions';
import { setVideoQuality } from '../../react/features/video-quality/actions';
import { toggleBackgroundEffect, toggleBlurredBackgroundEffect } from '../../react/features/virtual-background/actions';
import {
toggleBackgroundEffect,
toggleBlurredBackgroundEffect,
toggleStudioLightEffect
} from '../../react/features/virtual-background/actions';
import { VIRTUAL_BACKGROUND_TYPE } from '../../react/features/virtual-background/constants';
import { toggleWhiteboard } from '../../react/features/whiteboard/actions.web';
import { getJitsiMeetTransport } from '../transport';
@@ -843,12 +847,8 @@ function initCommands() {
const activeSession = getActiveSession(state, mode);
if (activeSession && activeSession.id) {
APP.store.dispatch(toggleScreenshotCaptureSummary(false));
conference.stopRecording(activeSession.id);
} else {
logger.error('No recording or streaming session found');
}
APP.store.dispatch(toggleScreenshotCaptureSummary(false));
conference.stopRecording(activeSession?.id);
},
'initiate-private-chat': participantId => {
const state = APP.store.getState();
@@ -928,6 +928,12 @@ function initCommands() {
virtualSource: backgroundImage
}, jitsiTrack));
},
'set-studio-light': (enabled, preset) => {
const tracks = APP.store.getState()['features/base/tracks'];
const jitsiTrack = getLocalVideoTrack(tracks)?.jitsiTrack;
APP.store.dispatch(toggleStudioLightEffect(jitsiTrack, enabled, preset));
},
'show-pip': () => {
APP.store.dispatch(showPiP());
},
@@ -1493,19 +1499,29 @@ class API {
* @returns {void}
*/
notifyReceivedChatMessage(
{ body, from, nick, privateMessage, ts } = {}) {
{ body, from, nick, privateMessage, ts, messageId, replyToMessageId } = {}) {
if (APP.conference.isLocalId(from)) {
return;
}
this._sendEvent({
const event = {
name: 'incoming-message',
from,
message: body,
nick,
privateMessage,
stamp: ts
});
};
if (typeof messageId === 'string' && messageId !== '') {
event.messageId = messageId;
}
if (typeof replyToMessageId === 'string' && replyToMessageId !== '') {
event.replyToMessageId = replyToMessageId;
}
this._sendEvent(event);
}
/**

View File

@@ -806,7 +806,11 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
* {{
* 'from': from,//JID of the user that sent the message
* 'nick': nick,//the nickname of the user that sent the message
* 'message': txt//the text of the message
* 'message': txt,//the text of the message
* 'privateMessage': privateMessage,//whether the message is private
* 'stamp': stamp,//optional timestamp when available
* 'messageId': messageId,//optional XMPP message id when available
* 'replyToMessageId': replyToMessageId//optional XEP-0461 reply target message id when available
* }}
* {@code outgoingMessage} - receives event notifications about outgoing
* messages. The listener will receive object with the following structure:

4502
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,11 +24,12 @@
"@giphy/js-fetch-api": "4.9.3",
"@giphy/react-components": "6.9.4",
"@giphy/react-native-sdk": "4.1.0",
"@jitsi/excalidraw": "https://github.com/jitsi/excalidraw/releases/download/v0.0.19/jitsi-excalidraw-0.0.19.tgz",
"@jitsi/excalidraw": "https://github.com/jitsi/excalidraw/releases/download/0.18.5/jitsi-excalidraw-v0.18.5.tgz",
"@jitsi/js-utils": "2.6.7",
"@jitsi/logger": "2.1.1",
"@jitsi/rnnoise-wasm": "0.2.1",
"@matrix-org/olm": "3.2.15",
"@mediapipe/selfie_segmentation": "^0.1.1675465747",
"@microsoft/microsoft-graph-client": "3.0.1",
"@mui/material": "5.12.1",
"@react-native-async-storage/async-storage": "1.23.1",
@@ -44,8 +45,12 @@
"@sayem314/react-native-keep-awake": "1.3.1",
"@stomp/stompjs": "7.0.0",
"@svgr/webpack": "6.3.1",
"@tensorflow/tfjs-backend-wasm": "3.13.0",
"@tensorflow/tfjs-core": "3.13.0",
"@tensorflow-models/body-segmentation": "^1.0.2",
"@tensorflow/tfjs-backend-wasm": "^4.22.0",
"@tensorflow/tfjs-backend-webgl": "^4.22.0",
"@tensorflow/tfjs-backend-webgpu": "^4.22.0",
"@tensorflow/tfjs-converter": "^4.22.0",
"@tensorflow/tfjs-core": "^4.22.0",
"@vladmandic/human": "2.6.5",
"@vladmandic/human-models": "2.5.9",
"@xmldom/xmldom": "0.8.7",
@@ -72,7 +77,7 @@
"js-md5": "0.6.1",
"js-sha512": "0.8.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2140.0.0+fe26afb0/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2143.0.0+733e66c6/lib-jitsi-meet.tgz",
"lodash-es": "4.17.23",
"null-loader": "4.0.1",
"optional-require": "1.0.3",

View File

@@ -1,6 +1 @@
*.tgz
tsconfig.json
.npmrc
.git
.gitignore
node_modules

View File

@@ -2,8 +2,7 @@
"name": "@jitsi/react-native-sdk",
"version": "0.0.0",
"description": "React Native SDK for Jitsi Meet.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "index.tsx",
"license": "Apache-2.0",
"author": "",
"homepage": "https://jitsi.org",
@@ -93,27 +92,9 @@
"@babel/plugin-proposal-optional-chaining": "0.0.0"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"postinstall": "node sdk_instructions.js",
"prepare": "node prepare_sdk.js && npm run build"
"prepare": "node prepare_sdk.js"
},
"files": [
"dist",
"android",
"ios",
"index.tsx",
"jitsi-meet-rnsdk.podspec",
"prepare_sdk.js",
"sdk_instructions.js",
"update_dependencies.js",
"update_sdk_dependencies.js",
"README.md",
"images",
"sounds",
"lang",
"modules",
"react"
],
"bugs": {
"url": "https://github.com/jitsi/jitsi-meet/issues"
},

View File

@@ -1,17 +0,0 @@
{
"extends": "../tsconfig.native.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"emitDeclarationOnly": false
},
"include": [
"index.tsx"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -61,10 +61,10 @@ type NotifyClickButtonKey = string |
ParticipantMenuButtonsWithNotifyClick;
export type NotifyClickButton = NotifyClickButtonKey |
{
key: NotifyClickButtonKey;
preventExecution: boolean;
};
{
key: NotifyClickButtonKey;
preventExecution: boolean;
};
export type Sounds = 'ASKED_TO_UNMUTE_SOUND' |
'E2EE_OFF_SOUND' |
@@ -145,6 +145,7 @@ export interface IWhiteboardConfig {
collabServerBaseUrl?: string;
enabled?: boolean;
limitUrl?: string;
storageBackendUrl?: string;
userLimit?: number;
}
@@ -196,7 +197,14 @@ export interface IConfig {
opusMaxAverageBitrate?: number | null;
stereo?: boolean;
};
/**
* @deprecated Use `transcription.autoTranscribeOnRecord` instead.
*/
autoCaptionOnRecord?: boolean;
/**
* @deprecated Use `lobby.autoKnock` instead.
*/
autoKnockLobby?: boolean;
backgroundAlpha?: number;
bosh?: string;
@@ -287,13 +295,22 @@ export interface IConfig {
disableCameraTintForeground?: boolean;
disableChat?: boolean;
disableChatSmileys?: boolean;
/**
* @deprecated Use `deeplinking.disabled` instead.
*/
disableDeepLinking?: boolean;
disableFilmstripAutohiding?: boolean;
disableFocus?: boolean;
disableIframeAPI?: boolean;
/**
* @deprecated Use `disabledSounds` instead.
*/
disableIncomingMessageSound?: boolean;
disableInitialGUM?: boolean;
disableInviteFunctions?: boolean;
/**
* @deprecated Use `disabledSounds` instead.
*/
disableJoinLeaveSounds?: boolean;
disableLocalVideoFlip?: boolean;
disableModeratorIndicator?: boolean;
@@ -303,9 +320,15 @@ export interface IConfig {
disableReactions?: boolean;
disableReactionsInChat?: boolean;
disableReactionsModeration?: boolean;
/**
* @deprecated Use `disabledSounds` instead.
*/
disableRecordAudioNotification?: boolean;
disableRemoteControl?: boolean;
disableRemoteMute?: boolean;
/**
* @deprecated Use `raisedHands.disableRemoveRaisedHandOnFocus` instead.
*/
disableRemoveRaisedHandOnFocus?: boolean;
disableResponsiveTiles?: boolean;
disableRtx?: boolean;
@@ -315,6 +338,9 @@ export interface IConfig {
disableShortcuts?: boolean;
disableShowMoreStats?: boolean;
disableSimulcast?: boolean;
/**
* @deprecated Use `speakerStats.disableSearch` instead.
*/
disableSpeakerStatsSearch?: boolean;
disableThirdPartyRequests?: boolean;
disableTileEnlargement?: boolean;
@@ -359,6 +385,9 @@ export interface IConfig {
enableEncodedTransformSupport?: boolean;
enableForcedReload?: boolean;
enableInsecureRoomNameWarning?: boolean;
/**
* @deprecated Use `lobby.enableChat` instead.
*/
enableLobbyChat?: boolean;
enableNoAudioDetection?: boolean;
enableNoisyMicDetection?: boolean;
@@ -368,6 +397,9 @@ export interface IConfig {
enableTalkWhileMuted?: boolean;
enableTcc?: boolean;
enableWebHIDFeature?: boolean;
/**
* @deprecated Use `welcomePage.disabled` instead.
*/
enableWelcomePage?: boolean;
etherpad_base?: string;
faceLandmarks?: {
@@ -379,7 +411,14 @@ export interface IConfig {
faceCenteringThreshold?: number;
};
feedbackPercentage?: number;
/**
* @deprecated Use `recordingService.enabled` instead.
*/
fileRecordingsServiceEnabled?: boolean;
/**
* @deprecated Use `recordingService.sharingEnabled` instead.
*/
fileRecordingsServiceSharingEnabled?: boolean;
fileSharing?: {
apiUrl?: string;
@@ -414,18 +453,27 @@ export interface IConfig {
baseUrl?: string;
disabled?: boolean;
};
/**
* @deprecated Use `gravatar.baseUrl` instead.
*/
gravatarBaseURL?: string;
guestDialOutStatusUrl?: string;
guestDialOutUrl?: string;
helpCentreURL?: string;
hiddenDomain?: string;
hiddenPremeetingButtons?: Array<'microphone' | 'camera' | 'select-background' | 'invite' | 'settings'>;
/**
* @deprecated Use `breakoutRooms.hideAddRoomButton` instead.
*/
hideAddRoomButton?: boolean;
hideConferenceSubject?: boolean;
hideConferenceTimer?: boolean;
hideDisplayName?: boolean;
hideDominantSpeakerBadge?: boolean;
hideEmailInSettings?: boolean;
/**
* @deprecated Use `securityUi.hideLobbyButton` instead.
*/
hideLobbyButton?: boolean;
hideLoginButton?: boolean;
hideParticipantsStats?: boolean;
@@ -462,6 +510,9 @@ export interface IConfig {
termsLink?: string;
validatorRegExpString?: string;
};
/**
* @deprecated Use `liveStreaming.enabled` instead.
*/
liveStreamingEnabled?: boolean;
lobby?: {
autoKnock?: boolean;
@@ -523,6 +574,9 @@ export interface IConfig {
};
preferBosh?: boolean;
preferVisitor?: boolean;
/**
* @deprecated Use `transcription.preferredLanguage` instead.
*/
preferredTranscribeLanguage?: string;
prejoinConfig?: {
enabled?: boolean;
@@ -585,6 +639,9 @@ export interface IConfig {
disabled?: boolean;
order?: Array<'role' | 'name' | 'hasLeft'>;
};
/**
* @deprecated Use `speakerStats.order` instead.
*/
speakerStatsOrder?: Array<'role' | 'name' | 'hasLeft'>;
startAudioMuted?: number;
startAudioOnly?: boolean;
@@ -627,7 +684,14 @@ export interface IConfig {
initialTimeout?: number;
timeout?: number;
};
/**
* @deprecated Use `transcription.useAppLanguage` instead.
*/
transcribeWithAppLanguage?: boolean;
/**
* @deprecated Use `transcription.enabled` instead.
*/
transcribingEnabled?: boolean;
transcription?: {
autoCaptionOnTranscribe?: boolean;
@@ -657,6 +721,19 @@ export interface IConfig {
mobileCodecPreferenceOrder?: Array<string>;
persist?: boolean;
};
virtualBackground?: {
edgeHigh?: number;
edgeLow?: number;
enableV2?: boolean;
inferenceStride?: number;
segmentationHeight?: number;
segmentationWidth?: number;
targetFps?: number;
temporalBlendRatio?: number;
testMode?: boolean;
tierOverride?: 'high' | 'low' | 'medium';
useInsertableStreams?: boolean;
};
visitors?: {
enableMediaOnPromote?: {
audio?: boolean;

View File

@@ -242,6 +242,7 @@ export default [
'useHostPageLocalStorage',
'useTurnUdp',
'videoQuality',
'virtualBackground',
'visitors.enableMediaOnPromote',
'visitors.hideVisitorCountForVisitors',
'visitors.showJoinMeetingDialog',

View File

@@ -89,6 +89,7 @@ import { default as IconRemoteControlStop } from './stop-remote-control.svg';
import { default as IconStop } from './stop.svg';
import { default as IconSubtitles } from './subtitles.svg';
import { default as IconTileView } from './tile-view.svg';
import { default as IconTranscription } from './transcription.svg';
import { default as IconTrash } from './trash.svg';
import { default as IconUserDeleted } from './user-deleted.svg';
import { default as IconUser } from './user.svg';
@@ -183,6 +184,7 @@ export const DEFAULT_ICON: Record<string, any> = {
IconPlus,
IconRaiseHand,
IconRecord,
IconTranscription,
IconRecordAccount,
IconRecordContact,
IconRecordLead,

View File

@@ -74,6 +74,7 @@ const {
IconPlus,
IconRaiseHand,
IconRecord,
IconTranscription,
IconRecordAccount,
IconRecordContact,
IconRecordLead,
@@ -194,6 +195,7 @@ export {
IconPlus,
IconRaiseHand,
IconRecord,
IconTranscription,
IconRecordAccount,
IconRecordContact,
IconRecordLead,

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5852 12.75L17.327 16.2909C17.5098 16.6626 17.9593 16.8157 18.331 16.6329C18.7027 16.4501 18.8558 16.0006 18.673 15.6289L12.6414 3.36696C12.2255 2.52157 11.017 2.53122 10.6146 3.38315L4.5718 16.1797C4.39494 16.5542 4.55519 16.9012 4.92974 17.0781C5.3043 17.255 5.65131 17.0947 5.82818 16.7202L7.85021 12.75H15.5852ZM14.8474 11.25L11.6388 4.72714L8.55854 11.25H14.8474Z" />
<path d="M3 20.25C3 19.8358 3.33579 19.5 3.75 19.5H20.25C20.6642 19.5 21 19.8358 21 20.25C21 20.6642 20.6642 21 20.25 21H3.75C3.33579 21 3 20.6642 3 20.25Z" />
</svg>

After

Width:  |  Height:  |  Size: 684 B

View File

@@ -146,11 +146,18 @@ export function shouldRenderVideoTrack(
* @returns {string}
*/
export const getSoundFileSrc = (file: string, language: string): string => {
if (!AudioSupportedLanguage[language as keyof typeof AudioSupportedLanguage]
|| language === AudioSupportedLanguage.en) {
if (!language) {
return file;
}
// Normalize language code: 'fr-CA' -> 'frCA' to match AudioSupportedLanguage enum and file naming
const normalizedLanguage = language.replace('-', '');
if (!AudioSupportedLanguage[normalizedLanguage as keyof typeof AudioSupportedLanguage]
|| normalizedLanguage === AudioSupportedLanguage.en) {
return file;
}
const fileTokens = file.split('.');
return `${fileTokens[0]}_${language}.${fileTokens[1]}`;
return `${fileTokens[0]}_${normalizedLanguage}.${fileTokens[1]}`;
};

View File

@@ -789,8 +789,9 @@ function _localRecordingUpdated({ dispatch, getState }: IStore, conference: IJit
participantId: string, newValue: boolean) {
const state = getState();
const participant = getParticipantById(state, participantId);
const currentValue = participant?.localRecording ?? false;
if (participant?.localRecording === newValue) {
if (currentValue === newValue) {
return;
}

View File

@@ -1,3 +1,6 @@
import { showWarningNotification } from '../../../features/notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../../features/notifications/constants';
import { backgroundEnabled } from '../../../features/virtual-background/actions';
import { IStore } from '../../app/types';
import { IStateful } from '../app/types';
import { isAdvancedAudioSettingsEnabled } from '../config/functions.any';
@@ -79,7 +82,15 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore,
}
// Filter any undefined values returned by Promise.resolve().
const effects = effectsArray.filter(effect => Boolean(effect));
const allEffects = effectsArray.filter(effect => Boolean(effect));
// Effects that set requiresPostConstructionApplication=true (e.g. V2 insertable streams path) cannot
// be passed to the JitsiLocalTrack constructor: their startEffect() returns a
// MediaStreamTrackGenerator whose getSettings() is empty until frames flow, crashing the
// constraint-caching code in the constructor. Split them out and apply via setEffect() after the
// tracks are created.
const effects = allEffects.filter((e: any) => !e.requiresPostConstructionApplication);
const postEffects = allEffects.filter((e: any) => Boolean(e.requiresPostConstructionApplication));
return JitsiMeetJS.createLocalTracks(
{
@@ -101,6 +112,39 @@ export function createLocalTracksF(options: ITrackOptions = {}, store?: IStore,
logger.error('Failed to create local tracks', options.devices, err);
return Promise.reject(err);
})
.then(async (localTracks: any[]) => {
// Apply post-construction effects (IS path) via setEffect on the video track.
for (const effect of postEffects as any[]) {
const videoTrack = localTracks.find((t: any) => effect.isEnabled(t));
if (videoTrack) {
try {
await videoTrack.setEffect(effect);
// V2 effects initialise asynchronously (worker spawn + model load).
if ((effect as any).initPromise instanceof Promise) {
await (effect as any).initPromise;
}
} catch (err) {
logger.error('Failed to apply post-construction effect', err);
try {
await videoTrack.setEffect(undefined);
} catch (cleanupErr) {
logger.warn('Failed to clear effect after init failure', cleanupErr);
}
store!.dispatch(backgroundEnabled(false));
store!.dispatch(showWarningNotification(
{ titleKey: 'virtualBackground.backgroundEffectError' },
NOTIFICATION_TIMEOUT_TYPE.LONG
));
}
}
}
return localTracks;
});
}));
}

View File

@@ -370,6 +370,7 @@ export const colorMap = {
recordingHighlightButtonIconDisabled: 'text03', // Recording highlight button disabled icon color
recordingNotificationText: 'surface01', // Recording notification text color
recordingNotificationAction: 'action01', // Recording notification action color
transcriptionIndicator: 'success01', // Transcription indicator background
// Virtual Background
virtualBackgroundBackground: 'ui01', // Virtual background picker background

View File

@@ -53,12 +53,12 @@ const IconButton: React.FC<IIconButtonProps> = ({
<TouchableHighlight
accessibilityLabel = { accessibilityLabel }
disabled = { disabled }
id = { id }
onPress = { onPress }
style = { [
iconButtonContainerStyles,
style
] as ViewStyle[] }
testID = { id }
underlayColor = { underlayColor }>
<Icon
color = { color }

View File

@@ -164,6 +164,7 @@ const Input = forwardRef<TextInput, IProps>(({
focused && styles.inputFocused,
error && styles.inputError
] as StyleProp<TextStyle> }
testID = { id }
textContentType = { textContentType }
value = { typeof value === 'number' ? `${value}` : value } />
{ clearable && !disabled && value !== '' && (

View File

@@ -326,6 +326,7 @@ export interface IPalette {
recordingNotificationAction: string;
recordingNotificationText: string;
recordingText: string;
transcriptionIndicator: string;
securityDialogBackground: string;
securityDialogBorder: string;
securityDialogSecondaryText: string;

View File

@@ -2,15 +2,20 @@ import { merge } from 'lodash-es';
import * as jitsiTokens from './jitsiTokens.json';
import * as tokens from './tokens.json';
import { IPalette } from './types';
/**
* Creates the color tokens based on the color theme and the association map.
*
* @param {Object} colorMap - A map between the token name and the actual color value.
* @param {Object} customTokens - Optional custom token overrides to merge before resolution.
* This allows custom branding colors to propagate through semantic token references.
* For example, if customTokens contains { action01: '#custom' }, then any semantic token
* that references 'action01' (like 'prejoinActionButtonPrimary') will resolve to '#custom'.
* @returns {Object}
*/
export function createColorTokens(colorMap: Object): any {
const allTokens = merge({}, tokens, jitsiTokens);
export function createColorTokens(colorMap: Object, customTokens?: Partial<IPalette>): any {
const allTokens = merge({}, tokens, jitsiTokens, customTokens || {});
const result: any = {};
// First pass: resolve tokens that reference allTokens directly

View File

@@ -1,11 +1,38 @@
import { isEqual } from 'lodash-es';
import { NIL, parse as parseUUID } from 'uuid';
import zxcvbn from 'zxcvbn';
// The null UUID.
const NIL_UUID = parseUUID(NIL);
const _zxcvbnCache = new Map();
let _zxcvbn: ((password: string) => { score: number; }) | null = null;
/**
* Triggers the asynchronous load of the zxcvbn library if not already loaded.
* Can be called early (e.g. on config load) to ensure the library is ready
* before the first call to {@link isInsecureRoomName}.
*
* @returns {void}
*/
export function preloadZxcvbn() {
_ensureZxcvbn();
}
/**
* Triggers the asynchronous load of the zxcvbn library if not already loaded.
*
* @returns {void}
*/
function _ensureZxcvbn() {
if (_zxcvbn !== null) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
import(/* webpackChunkName: "zxcvbn" */ 'zxcvbn').then((m: any) => {
_zxcvbn = m.default ?? m;
});
}
/**
* Checks if the given string is a valid UUID or not.
@@ -27,16 +54,23 @@ function isValidUUID(str: string) {
/**
* Checks a room name and caches the result.
* Returns undefined if zxcvbn is not yet loaded.
*
* @param {string} roomName - The room name.
* @returns {Object}
* @returns {Object|undefined}
*/
function _checkRoomName(roomName = '') {
if (_zxcvbnCache.has(roomName)) {
return _zxcvbnCache.get(roomName);
}
const result = zxcvbn(roomName);
_ensureZxcvbn();
if (!_zxcvbn) {
return undefined;
}
const result = _zxcvbn(roomName);
_zxcvbnCache.set(roomName, result);
@@ -45,6 +79,7 @@ function _checkRoomName(roomName = '') {
/**
* Returns true if the room name is considered a weak (insecure) one.
* Returns false (treats as secure) while the zxcvbn library is still loading.
*
* @param {string} roomName - The room name.
* @returns {boolean}
@@ -52,5 +87,5 @@ function _checkRoomName(roomName = '') {
export default function isInsecureRoomName(roomName = ''): boolean {
// room names longer than 200 chars we consider secure
return !isValidUUID(roomName) && (roomName.length < 200 && _checkRoomName(roomName).score < 3);
return !isValidUUID(roomName) && (roomName.length < 200 && (_checkRoomName(roomName)?.score ?? 3) < 3);
}

View File

@@ -254,6 +254,7 @@ export function moveToRoom(roomId?: string) {
}
APP.conference.joinRoom(_roomId, {
isBreakoutRoom: true,
startWithAudioMuted: isAudioMuted,
startWithVideoMuted: isVideoMuted
});

View File

@@ -6,7 +6,7 @@
* displayName: string
* hasRead: boolean,
* id: string,
* messageType: string,
* messageType: ChatMessageType,
* message: string,
* timestamp: string,
* }

View File

@@ -42,7 +42,7 @@ import { ChatTabs } from './constants';
* displayName: string,
* hasRead: boolean,
* message: string,
* messageType: string,
* messageType: ChatMessageType,
* timestamp: string,
* isReaction: boolean
* }}

View File

@@ -151,7 +151,7 @@ const useStyles = makeStyles<{
'*': {
userSelect: 'text',
'-webkit-user-select': 'text'
WebkitUserSelect: 'text'
}
},

View File

@@ -19,17 +19,17 @@ export const INCOMING_MSG_SOUND_ID = 'INCOMING_MSG_SOUND';
/**
* The {@code messageType} of error (system) messages.
*/
export const MESSAGE_TYPE_ERROR = 'error';
export const MESSAGE_TYPE_ERROR = 'error' as const;
/**
* The {@code messageType} of local messages.
*/
export const MESSAGE_TYPE_LOCAL = 'local';
export const MESSAGE_TYPE_LOCAL = 'local' as const;
/**
* The {@code messageType} of remote messages.
*/
export const MESSAGE_TYPE_REMOTE = 'remote';
export const MESSAGE_TYPE_REMOTE = 'remote' as const;
export const SMALL_WIDTH_THRESHOLD = 580;

View File

@@ -13,6 +13,7 @@ import { getParticipantById, isPrivateChatEnabled } from '../base/participants/f
import { IParticipant } from '../base/participants/types';
import { escapeRegexp } from '../base/util/helpers';
import { arePollsDisabled } from '../conference/functions.any';
import { getCustomPanelWidth } from '../custom-panel/functions';
import { isFileSharingEnabled } from '../file-sharing/functions.any';
import { getParticipantsPaneWidth } from '../participants-pane/functions';
import { isCCTabEnabled } from '../subtitles/functions.any';
@@ -311,7 +312,10 @@ export function isSendGroupChatDisabled(state: IReduxState): boolean {
export function getChatMaxSize(state: IReduxState): number {
const { clientWidth } = state['features/base/responsive-ui'];
return Math.max(clientWidth - getParticipantsPaneWidth(state) - VIDEO_SPACE_MIN_SIZE, 0);
return Math.max(
clientWidth - getParticipantsPaneWidth(state) - getCustomPanelWidth(state) - VIDEO_SPACE_MIN_SIZE,
0
);
}
/**

View File

@@ -406,7 +406,7 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
JitsiConferenceEvents.MESSAGE_RECEIVED,
/* eslint-disable max-params */
(participantId: string, message: string, timestamp: number,
displayName: string, isFromVisitor: boolean, messageId: string, source: string) => {
displayName: string, isFromVisitor: boolean, messageId: string, source: string, replyToId?: string) => {
/* eslint-enable max-params */
_onConferenceMessageReceived(store, {
// in case of messages coming from visitors we can have unknown id
@@ -416,6 +416,7 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
displayName,
isFromVisitor,
messageId,
replyToMessageId: replyToId,
source,
privateMessage: false
});
@@ -441,7 +442,8 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
conference.on(
JitsiConferenceEvents.PRIVATE_MESSAGE_RECEIVED,
(participantId: string, message: string, timestamp: number, messageId: string, displayName?: string, isFromVisitor?: boolean) => {
(participantId: string, message: string, timestamp: number, messageId: string, displayName?: string,
isFromVisitor?: boolean, replyToId?: string) => {
_onConferenceMessageReceived(store, {
participantId,
message,
@@ -449,7 +451,8 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
displayName,
messageId,
privateMessage: true,
isFromVisitor
isFromVisitor,
replyToMessageId: replyToId
});
}
);
@@ -468,9 +471,9 @@ function _addChatMsgListener(conference: IJitsiConference, store: IStore) {
* @returns {void}
*/
function _onConferenceMessageReceived(store: IStore,
{ displayName, isFromVisitor, message, messageId, participantId, privateMessage, timestamp, source }: {
{ displayName, isFromVisitor, message, messageId, participantId, privateMessage, replyToMessageId, timestamp, source }: {
displayName?: string; isFromVisitor?: boolean; message: string; messageId?: string;
participantId: string; privateMessage: boolean; source?: string; timestamp: number; }
participantId: string; privateMessage: boolean; replyToMessageId?: string; source?: string; timestamp: number; }
) {
const isGif = isGifEnabled(store.getState()) && isGifMessage(message);
@@ -490,6 +493,7 @@ function _onConferenceMessageReceived(store: IStore,
lobbyChat: false,
timestamp,
messageId,
replyToMessageId,
source
}, true, isGif);
}
@@ -599,9 +603,10 @@ function getLobbyChatDisplayName(state: IReduxState, participantId: string) {
* @returns {void}
*/
function _handleReceivedMessage({ dispatch, getState }: IStore,
{ displayName, isFromVisitor, lobbyChat, message, messageId, participantId, privateMessage, source, timestamp }: {
{ displayName, isFromVisitor, lobbyChat, message, messageId, participantId, privateMessage, replyToMessageId, source, timestamp }: {
displayName?: string; isFromVisitor?: boolean; lobbyChat: boolean; message: string;
messageId?: string; participantId: string; privateMessage: boolean; source?: string; timestamp: number; },
messageId?: string; participantId: string; privateMessage: boolean; replyToMessageId?: string;
source?: string; timestamp: number; },
shouldPlaySound = true,
isReaction = false
) {
@@ -652,6 +657,7 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
recipient: getParticipantDisplayName(state, localParticipant?.id ?? ''),
timestamp: millisecondsTimestamp,
messageId,
replyToMessageId,
isReaction,
isFromVisitor,
isFromGuest: source === 'guest'
@@ -687,7 +693,9 @@ function _handleReceivedMessage({ dispatch, getState }: IStore,
from: participantId,
nick: notificationDisplayName,
privateMessage,
ts: timestamp
ts: timestamp,
messageId: newMessage.messageId,
replyToMessageId: newMessage.replyToMessageId
});
}
}

View File

@@ -83,6 +83,7 @@ ReducerRegistry.register<IChatState>('features/chat', (state = DEFAULT_STATE, ac
privateMessage: action.privateMessage,
lobbyChat: action.lobbyChat,
recipient: action.recipient,
replyToMessageId: action.replyToMessageId,
sentToVisitor: Boolean(action.sentToVisitor),
timestamp: action.timestamp
};

View File

@@ -3,9 +3,20 @@ import { WithTranslation } from 'react-i18next';
import { IStore } from '../app/types';
import { IFileMetadata } from '../file-sharing/types';
import {
MESSAGE_TYPE_ERROR,
MESSAGE_TYPE_LOCAL,
MESSAGE_TYPE_REMOTE
} from './constants';
export type ChatMessageType =
| typeof MESSAGE_TYPE_LOCAL
| typeof MESSAGE_TYPE_ERROR
| typeof MESSAGE_TYPE_REMOTE;
export interface IMessage {
displayName: string;
error?: Object;
error?: unknown;
fileMetadata?: IFileMetadata;
isFromGuest?: boolean;
isFromVisitor?: boolean;
@@ -13,11 +24,15 @@ export interface IMessage {
lobbyChat: boolean;
message: string;
messageId: string;
messageType: string;
messageType: ChatMessageType;
participantId: string;
privateMessage: boolean;
reactions: Map<string, Set<string>>;
recipient: string;
/**
* When set, XMPP message id of the message this one replies to (XEP-0461), from lib-jitsi-meet.
*/
replyToMessageId?: string;
sentToVisitor?: boolean;
timestamp: number;
}

View File

@@ -6,6 +6,7 @@ import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { openHighlightDialog } from '../../../recording/actions.native';
import HighlightButton from '../../../recording/components/Recording/native/HighlightButton';
import RecordingLabel from '../../../recording/components/native/RecordingLabel';
import TranscribingLabel from '../../../recording/components/native/TranscribingLabel';
import { isLiveStreamingRunning } from '../../../recording/functions';
import VisitorsCountLabel from '../../../visitors/components/native/VisitorsCountLabel';
@@ -14,6 +15,7 @@ import {
LABEL_ID_RAISED_HANDS_COUNT,
LABEL_ID_RECORDING,
LABEL_ID_STREAMING,
LABEL_ID_TRANSCRIBING,
LABEL_ID_VISITORS_COUNT,
LabelHitSlop
} from './constants';
@@ -47,6 +49,11 @@ const AlwaysOnLabels = ({ createOnPress }: IProps) => {
<RecordingLabel mode = { JitsiRecordingConstants.mode.STREAM } />
</TouchableOpacity>
}
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = { createOnPress(LABEL_ID_TRANSCRIBING) } >
<TranscribingLabel />
</TouchableOpacity>
<TouchableOpacity
hitSlop = { LabelHitSlop }
onPress = { openHighlightDialogCallback }>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import RecordingExpandedLabel from '../../../recording/components/native/RecordingExpandedLabel';
import TranscribingExpandedLabel from '../../../recording/components/native/TranscribingExpandedLabel';
import VideoQualityExpandedLabel from '../../../video-quality/components/VideoQualityExpandedLabel.native';
import InsecureRoomNameExpandedLabel from './InsecureRoomNameExpandedLabel';
@@ -22,6 +23,7 @@ export const EXPANDED_LABEL_TIMEOUT = 5000;
export const LABEL_ID_QUALITY = 'quality';
export const LABEL_ID_RECORDING = 'recording';
export const LABEL_ID_STREAMING = 'streaming';
export const LABEL_ID_TRANSCRIBING = 'transcribing';
export const LABEL_ID_INSECURE_ROOM_NAME = 'insecure-room-name';
export const LABEL_ID_RAISED_HANDS_COUNT = 'raised-hands-count';
export const LABEL_ID_VISITORS_COUNT = 'visitors-count';
@@ -56,6 +58,10 @@ export const EXPANDED_LABELS: {
},
alwaysOn: true
},
[LABEL_ID_TRANSCRIBING]: {
component: TranscribingExpandedLabel,
alwaysOn: true
},
[LABEL_ID_INSECURE_ROOM_NAME]: {
component: InsecureRoomNameExpandedLabel
},

View File

@@ -7,6 +7,7 @@ import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import E2EELabel from '../../../e2ee/components/E2EELabel';
import HighlightButton from '../../../recording/components/Recording/web/HighlightButton';
import RecordingLabel from '../../../recording/components/web/RecordingLabel';
import TranscribingLabel from '../../../recording/components/web/TranscribingLabel';
import { showToolbox } from '../../../toolbox/actions.web';
import { isToolboxVisible } from '../../../toolbox/functions.web';
import VideoQualityLabel from '../../../video-quality/components/VideoQualityLabel.web';
@@ -79,6 +80,7 @@ const COMPONENTS: Array<{
<>
<RecordingLabel mode = { JitsiRecordingConstants.mode.FILE } />
<RecordingLabel mode = { JitsiRecordingConstants.mode.STREAM } />
<TranscribingLabel />
</>
),
id: 'recording'

View File

@@ -12,3 +12,18 @@ export const CUSTOM_PANEL_OPEN = 'CUSTOM_PANEL_OPEN';
* Action type to enable or disable the custom panel dynamically.
*/
export const SET_CUSTOM_PANEL_ENABLED = 'SET_CUSTOM_PANEL_ENABLED';
/**
* Action type to set the custom panel width (responsive adjustments).
*/
export const SET_CUSTOM_PANEL_WIDTH = 'SET_CUSTOM_PANEL_WIDTH';
/**
* Action type to set the user-preferred custom panel width (user drag).
*/
export const SET_USER_CUSTOM_PANEL_WIDTH = 'SET_USER_CUSTOM_PANEL_WIDTH';
/**
* Action type to indicate whether the custom panel is being resized.
*/
export const SET_CUSTOM_PANEL_IS_RESIZING = 'SET_CUSTOM_PANEL_IS_RESIZING';

View File

@@ -1,13 +1,17 @@
import {
CUSTOM_PANEL_CLOSE,
CUSTOM_PANEL_OPEN,
SET_CUSTOM_PANEL_ENABLED
SET_CUSTOM_PANEL_ENABLED,
SET_CUSTOM_PANEL_IS_RESIZING,
SET_CUSTOM_PANEL_WIDTH,
SET_USER_CUSTOM_PANEL_WIDTH
} from './actionTypes';
/**
* Action to close the custom panel.
*
* @returns {Object} The action object.
* NOTE: this action is used in the branding files.
*/
export function close() {
return {
@@ -38,3 +42,42 @@ export function setCustomPanelEnabled(enabled: boolean) {
enabled
};
}
/**
* Sets the custom panel width (used for responsive adjustments).
*
* @param {number} width - The new width of the custom panel.
* @returns {Object} The action object.
*/
export function setCustomPanelWidth(width: number) {
return {
type: SET_CUSTOM_PANEL_WIDTH,
width
};
}
/**
* Sets the user-preferred custom panel width (triggered by user drag).
*
* @param {number} width - The new width of the custom panel.
* @returns {Object} The action object.
*/
export function setUserCustomPanelWidth(width: number) {
return {
type: SET_USER_CUSTOM_PANEL_WIDTH,
width
};
}
/**
* Sets whether the user is currently resizing the custom panel.
*
* @param {boolean} resizing - Whether the panel is being resized.
* @returns {Object} The action object.
*/
export function setCustomPanelIsResizing(resizing: boolean) {
return {
type: SET_CUSTOM_PANEL_IS_RESIZING,
resizing
};
}

View File

@@ -1,10 +1,260 @@
/**
* Custom panel placeholder component.
* This file is overridden by jitsi-meet-branding at build time
* to provide the actual panel implementation with iframe content.
*
* @returns {null} This placeholder renders nothing.
*/
const CustomPanel = (): null => null;
import { throttle } from 'lodash-es';
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
export default CustomPanel;
import { IReduxState } from '../../../app/types';
import { isTouchDevice, shouldEnableResize } from '../../../base/environment/utils';
import { setCustomPanelIsResizing, setUserCustomPanelWidth } from '../../actions.web';
import {
CUSTOM_PANEL_DRAG_HANDLE_HEIGHT,
CUSTOM_PANEL_DRAG_HANDLE_OFFSET,
CUSTOM_PANEL_DRAG_HANDLE_WIDTH,
CUSTOM_PANEL_TOUCH_HANDLE_SIZE,
DEFAULT_CUSTOM_PANEL_WIDTH
} from '../../constants';
import { getCustomPanelMaxSize, getCustomPanelOpen, isCustomPanelEnabled } from '../../functions';
import CustomPanelContent from './CustomPanelContent';
interface IStylesProps {
/**
* Whether the panel is currently being resized.
*/
isResizing: boolean;
/**
* Whether the device supports touch.
*/
isTouch: boolean;
/**
* Whether resize is enabled.
*/
resizeEnabled: boolean;
/**
* The current width of the panel.
*/
width: number;
}
const useStyles = makeStyles<IStylesProps>()((theme, { isResizing, isTouch, resizeEnabled, width }) => {
return {
container: {
backgroundColor: theme.palette.ui01,
flexShrink: 0,
overflow: 'hidden',
position: 'relative',
transition: isResizing ? undefined : 'width .16s ease-in-out',
width: `${width}px`,
zIndex: 0,
display: 'flex',
flexDirection: 'column',
height: '100%',
// On non-touch devices (desktop), show handle on hover.
// On touch devices, handle is always visible if resize is enabled.
...(!isTouch && {
'&:hover, &:focus-within': {
'& .customPanelDragHandleContainer': {
visibility: 'visible'
}
}
}),
'@media (max-width: 580px)': {
height: '100dvh',
position: 'fixed',
left: 0,
right: 0,
top: 0,
width: '100%',
zIndex: 301
}
},
contentContainer: {
flex: 1,
overflow: 'hidden',
position: 'relative',
width: '100%',
height: '100%'
},
dragHandleContainer: {
height: '100%',
// Touch devices need larger hit target but positioned to not take extra space.
width: isTouch ? `${CUSTOM_PANEL_TOUCH_HANDLE_SIZE}px` : `${CUSTOM_PANEL_DRAG_HANDLE_WIDTH}px`,
backgroundColor: 'transparent',
position: 'absolute',
cursor: 'col-resize',
display: resizeEnabled ? 'flex' : 'none',
alignItems: 'center',
justifyContent: 'center',
// On touch devices, always visible if resize enabled. On desktop, hidden by default.
visibility: (isTouch && resizeEnabled) ? 'visible' : 'hidden',
// Position on LEFT edge of panel (custom panel is rightmost in layout).
left: isTouch
? `${CUSTOM_PANEL_DRAG_HANDLE_OFFSET
- Math.floor((CUSTOM_PANEL_TOUCH_HANDLE_SIZE - CUSTOM_PANEL_DRAG_HANDLE_WIDTH) / 2)}px`
: `${CUSTOM_PANEL_DRAG_HANDLE_OFFSET}px`,
top: 0,
zIndex: 2,
// Prevent touch scrolling while dragging.
touchAction: 'none',
'&:hover': {
'& .customPanelDragHandle': {
backgroundColor: theme.palette.icon01
}
},
'&.visible': {
visibility: 'visible',
'& .customPanelDragHandle': {
backgroundColor: theme.palette.icon01
}
}
},
dragHandle: {
// Keep the same visual appearance on all devices.
backgroundColor: theme.palette.icon02,
height: `${CUSTOM_PANEL_DRAG_HANDLE_HEIGHT}px`,
width: `${CUSTOM_PANEL_DRAG_HANDLE_WIDTH / 3}px`,
borderRadius: '1px',
// Make more visible when actively shown on touch.
...(isTouch && resizeEnabled && {
backgroundColor: theme.palette.icon01
})
}
};
});
/**
* Custom panel container component that handles resize, close button,
* and renders CustomPanelContent inside it.
*
* @returns {JSX.Element | null} The custom panel or null if not open.
*/
export default function CustomPanel(): JSX.Element | null {
const dispatch = useDispatch();
const enabled = useSelector(isCustomPanelEnabled);
const paneOpen = useSelector(getCustomPanelOpen);
const panelWidth = useSelector((state: IReduxState) =>
state['features/custom-panel']?.width?.current ?? DEFAULT_CUSTOM_PANEL_WIDTH);
const isResizing = useSelector((state: IReduxState) =>
state['features/custom-panel']?.isResizing ?? false);
const maxPanelWidth = useSelector(getCustomPanelMaxSize);
const isTouch = isTouchDevice();
const resizeEnabled = shouldEnableResize();
const { classes, cx } = useStyles({ isResizing, width: panelWidth, isTouch, resizeEnabled });
const [ isMouseDown, setIsMouseDown ] = useState(false);
const [ mousePosition, setMousePosition ] = useState<number | null>(null);
const [ dragPanelWidth, setDragPanelWidth ] = useState<number | null>(null);
/**
* Handles pointer down on the drag handle.
* Supports both mouse and touch events via Pointer Events API.
*
* @param {React.PointerEvent} e - The pointer down event.
* @returns {void}
*/
const onDragHandlePointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
// Capture the pointer to ensure we receive all pointer events
// even if the pointer moves outside the element.
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setIsMouseDown(true);
setMousePosition(e.clientX);
setDragPanelWidth(panelWidth);
dispatch(setCustomPanelIsResizing(true));
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [ panelWidth, dispatch ]);
/**
* Handles pointer up to end drag resize.
*
* @returns {void}
*/
const onDragPointerUp = useCallback(() => {
if (isMouseDown) {
setIsMouseDown(false);
dispatch(setCustomPanelIsResizing(false));
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
}, [ isMouseDown, dispatch ]);
/**
* Handles pointer move during drag resize.
* Handle is on the LEFT edge, so dragging left (negative diff) widens the panel.
*
* @param {PointerEvent} e - The pointermove event.
* @returns {void}
*/
const onPanelResize = useCallback(throttle((e: PointerEvent) => {
if (isMouseDown && mousePosition !== null && dragPanelWidth !== null) {
const diff = e.clientX - mousePosition;
// Handle is on LEFT edge: dragging left (negative diff) increases width.
const newWidth = Math.max(
Math.min(dragPanelWidth - diff, maxPanelWidth),
DEFAULT_CUSTOM_PANEL_WIDTH
);
if (newWidth !== panelWidth) {
dispatch(setUserCustomPanelWidth(newWidth));
}
}
}, 50, {
leading: true,
trailing: false
}), [ isMouseDown, mousePosition, dragPanelWidth, panelWidth, maxPanelWidth, dispatch ]);
// Set up global event listeners for drag tracking.
useEffect(() => {
document.addEventListener('pointerup', onDragPointerUp);
document.addEventListener('pointermove', onPanelResize);
return () => {
document.removeEventListener('pointerup', onDragPointerUp);
document.removeEventListener('pointermove', onPanelResize);
};
}, [ onDragPointerUp, onPanelResize ]);
if (!enabled || !paneOpen) {
return null;
}
return (
<div
className = { classes.container }
id = 'custom-panel'>
<div
className = { cx(
classes.dragHandleContainer,
(isMouseDown || isResizing) && 'visible',
'customPanelDragHandleContainer'
) }
onPointerDown = { onDragHandlePointerDown }>
<div className = { cx(classes.dragHandle, 'customPanelDragHandle') } />
</div>
<div className = { classes.contentContainer }>
<CustomPanelContent />
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
/**
* Custom panel content placeholder component.
* This file is overridden by jitsi-meet-branding at build time
* to provide the actual panel content (e.g. iframe).
*
* @returns {null} This placeholder renders nothing.
*/
export default function CustomPanelContent(): null {
return null;
}

View File

@@ -2,3 +2,24 @@
* Default width for the custom panel in pixels.
*/
export const DEFAULT_CUSTOM_PANEL_WIDTH = 315;
/**
* Visual width of the drag handle in pixels.
*/
export const CUSTOM_PANEL_DRAG_HANDLE_WIDTH = 9;
/**
* Visual height of the drag handle in pixels.
*/
export const CUSTOM_PANEL_DRAG_HANDLE_HEIGHT = 100;
/**
* Touch target size for the drag handle on touch devices.
* Provides adequate hit area (44px) for comfortable tapping.
*/
export const CUSTOM_PANEL_TOUCH_HANDLE_SIZE = 44;
/**
* Offset from the panel edge for positioning the drag handle.
*/
export const CUSTOM_PANEL_DRAG_HANDLE_OFFSET = 4;

View File

@@ -0,0 +1,5 @@
/**
* Custom panel functions placeholder.
* Override to add custom panel functionality.
*/
export {}; // we need this just to make TS happy!

View File

@@ -1,7 +1,12 @@
import { IReduxState } from '../app/types';
import { CHAT_SIZE } from '../chat/constants';
import { getParticipantsPaneWidth } from '../participants-pane/functions';
import { VIDEO_SPACE_MIN_SIZE } from '../video-layout/constants';
import { DEFAULT_CUSTOM_PANEL_WIDTH } from './constants';
export * from './functions.custom';
/**
* Returns whether the custom panel is enabled based on Redux state.
* The feature is disabled by default and can be enabled dynamically via console.
@@ -13,35 +18,6 @@ export function isCustomPanelEnabled(state: IReduxState): boolean {
return Boolean(state['features/custom-panel']?.enabled);
}
/**
* Returns the custom panel URL.
* Override to provide the actual URL.
*
* @returns {string} The custom panel URL.
*/
export function getCustomPanelUrl(): string {
return '';
}
/**
* Returns the custom panel button icon.
* Override to provide the actual icon.
*
* @returns {Function | undefined} The icon component.
*/
export function getCustomPanelIcon(): Function | undefined {
return undefined;
}
/**
* Returns the configured panel width.
*
* @returns {number} The panel width in pixels.
*/
export function getCustomPanelConfiguredWidth(): number {
return DEFAULT_CUSTOM_PANEL_WIDTH;
}
/**
* Returns whether the custom panel is currently open.
*
@@ -52,6 +28,17 @@ export function getCustomPanelOpen(state: IReduxState): boolean {
return Boolean(state['features/custom-panel']?.isOpen);
}
/**
* Returns the current configured width of the custom panel from Redux state.
* Falls back to the default width if no dynamic width is set.
*
* @param {IReduxState} state - The Redux state.
* @returns {number} The panel width in pixels.
*/
export function getCustomPanelConfiguredWidth(state: IReduxState): number {
return state['features/custom-panel']?.width?.current ?? DEFAULT_CUSTOM_PANEL_WIDTH;
}
/**
* Returns the current panel width (0 if closed or disabled).
*
@@ -63,5 +50,20 @@ export function getCustomPanelWidth(state: IReduxState): number {
return 0;
}
return getCustomPanelOpen(state) ? getCustomPanelConfiguredWidth() : 0;
return getCustomPanelOpen(state) ? getCustomPanelConfiguredWidth(state) : 0;
}
/**
* Calculates the maximum width available for the custom panel based on the
* current window size and other open UI panels.
*
* @param {IReduxState} state - The Redux state.
* @returns {number} The maximum width in pixels. Returns 0 if no space is available.
*/
export function getCustomPanelMaxSize(state: IReduxState): number {
const { clientWidth } = state['features/base/responsive-ui'];
const { isOpen: isChatOpen, width: chatWidth } = state['features/chat'];
const chatPanelWidth = isChatOpen ? (chatWidth?.current ?? CHAT_SIZE) : 0;
return Math.max(clientWidth - chatPanelWidth - getParticipantsPaneWidth(state) - VIDEO_SPACE_MIN_SIZE, 0);
}

View File

@@ -0,0 +1,4 @@
/**
* Custom panel middleware placeholder.
* Override to add custom panel functionality.
*/

View File

@@ -1,4 +1,2 @@
/**
* Custom panel middleware placeholder.
* Override to add custom panel functionality.
*/
import './middleware.custom.web';
import './subscriber.web';

View File

@@ -1,10 +1,15 @@
import PersistenceRegistry from '../base/redux/PersistenceRegistry';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
CUSTOM_PANEL_CLOSE,
CUSTOM_PANEL_OPEN,
SET_CUSTOM_PANEL_ENABLED
SET_CUSTOM_PANEL_ENABLED,
SET_CUSTOM_PANEL_IS_RESIZING,
SET_CUSTOM_PANEL_WIDTH,
SET_USER_CUSTOM_PANEL_WIDTH
} from './actionTypes';
import { DEFAULT_CUSTOM_PANEL_WIDTH } from './constants';
/**
* The state of the custom panel feature.
@@ -21,13 +26,48 @@ export interface ICustomPanelState {
* Whether the custom panel is currently open.
*/
isOpen: boolean;
/**
* Whether the user is currently resizing the custom panel.
*/
isResizing: boolean;
/**
* The width state of the custom panel.
*/
width: {
/**
* The current display width in pixels.
*/
current: number;
/**
* The user-preferred width set via drag resize, or null if not set.
*/
userSet: number | null;
};
}
const DEFAULT_STATE: ICustomPanelState = {
enabled: false,
isOpen: false
isOpen: false,
isResizing: false,
width: {
current: DEFAULT_CUSTOM_PANEL_WIDTH,
userSet: null
}
};
/**
* Persist only the width subtree so the user's preferred panel width
* survives page reloads.
*/
PersistenceRegistry.register('features/custom-panel', {
enabled: true,
width: true
});
/**
* Listen for actions that mutate the custom panel state.
*/
@@ -52,6 +92,30 @@ ReducerRegistry.register(
enabled: action.enabled
};
case SET_CUSTOM_PANEL_WIDTH:
return {
...state,
width: {
...state.width,
current: action.width
}
};
case SET_USER_CUSTOM_PANEL_WIDTH:
return {
...state,
width: {
current: action.width,
userSet: action.width
}
};
case SET_CUSTOM_PANEL_IS_RESIZING:
return {
...state,
isResizing: action.resizing
};
default:
return state;
}

View File

@@ -0,0 +1,74 @@
// @ts-ignore
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { clientResized } from '../base/responsive-ui/actions';
import { setCustomPanelWidth } from './actions.web';
import { DEFAULT_CUSTOM_PANEL_WIDTH } from './constants';
import { getCustomPanelMaxSize } from './functions';
interface IListenerState {
clientWidth: number;
isOpen: boolean;
maxWidth: number;
width: {
current: number;
userSet: number | null;
};
}
/**
* Listens for changes in the client width and custom panel width
* to determine when to adjust the panel size for responsive behavior.
*/
StateListenerRegistry.register(
/* selector */ state => {
return {
clientWidth: state['features/base/responsive-ui']?.clientWidth,
isOpen: state['features/custom-panel'].isOpen,
width: state['features/custom-panel'].width,
maxWidth: getCustomPanelMaxSize(state)
};
},
/* listener */ (
currentState: IListenerState,
{ dispatch },
previousState: IListenerState
) => {
if (currentState.isOpen
&& (currentState.clientWidth !== previousState.clientWidth
|| currentState.width !== previousState.width)) {
const { userSet = 0 } = currentState.width;
const { maxWidth } = currentState;
let panelWidthChanged = false;
if (currentState.clientWidth !== previousState.clientWidth) {
if (userSet !== null) {
// If userSet is set, clamp it within the new bounds.
// This handles the case when the screen gets smaller and
// the user-set width exceeds the max, or when the screen
// gets bigger and we can restore the user-set width.
dispatch(setCustomPanelWidth(
Math.max(Math.min(maxWidth, userSet), DEFAULT_CUSTOM_PANEL_WIDTH)
));
panelWidthChanged = true;
}
// else { // when userSet is null:
// no-op. The custom panel width will be the default one which is the min too.
// }
} else {
// Width changed (not clientWidth) — panel was resized by user.
panelWidthChanged = true;
}
if (panelWidthChanged) {
const { innerWidth, innerHeight } = window;
// Recalculate videoSpaceWidth since it depends on the custom panel width.
dispatch(clientResized(innerWidth, innerHeight));
// Recompute the large video size.
VideoLayout.onResize();
}
}
});

View File

@@ -53,8 +53,13 @@ export function createMuiBrandingTheme(customTheme: Theme) {
spacing: customSpacing
} = customTheme;
const newPalette = createColorTokens(colorMap);
// Pass customPalette to createColorTokens so that custom colors are merged
// BEFORE token resolution. This ensures that semantic tokens (like prejoinActionButtonPrimary)
// that reference base tokens (like action01) will resolve to the custom color values.
const newPalette = createColorTokens(colorMap, customPalette);
// Also apply overwriteRecurrsive for any direct palette key overrides that may not be
// handled through token resolution (e.g., if customer provides semantic token names directly).
if (customPalette) {
overwriteRecurrsive(newPalette, customPalette);
}

View File

@@ -4,6 +4,7 @@ const generateDownloadUrl = async (url: string) => {
const blob = new Blob([ respBlob ]);
// @ts-ignore
return URL.createObjectURL(blob);
};
@@ -22,6 +23,8 @@ export const downloadFile = async (url: string, fileName: string) => {
// fix for certain browsers
setTimeout(() => {
// @ts-ignore
URL.revokeObjectURL(dowloadUrl);
}, 0);
};

View File

@@ -1,41 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { isMobileBrowser } from '../../../base/environment/utils';
import i18next from '../../../base/i18n/i18next';
import { parseURLParams } from '../../../base/util/parseURLParams';
import { DIAL_IN_INFO_PAGE_PATH_NAME } from '../../constants';
import DialInSummary from '../dial-in-summary/web/DialInSummary';
import NoRoomError from './NoRoomError.web';
/**
* TODO: This seems unused, so we can drop it.
*/
document.addEventListener('DOMContentLoaded', () => {
// @ts-ignore
const { room } = parseURLParams(window.location, true, 'search');
const { href } = window.location;
const ix = href.indexOf(DIAL_IN_INFO_PAGE_PATH_NAME);
const url = (ix > 0 ? href.substring(0, ix) : href) + room;
/* eslint-disable-next-line react/no-deprecated */
ReactDOM.render(
<I18nextProvider i18n = { i18next }>
{ room
? <DialInSummary
className = 'dial-in-page'
clickableNumbers = { isMobileBrowser() }
room = { decodeURIComponent(room) }
url = { url } />
: <NoRoomError className = 'dial-in-page' /> }
</I18nextProvider>,
document.getElementById('react')
);
});
window.addEventListener('beforeunload', () => {
/* eslint-disable-next-line react/no-deprecated */
ReactDOM.unmountComponentAtNode(document.getElementById('react')!);
});

View File

@@ -38,7 +38,16 @@ ReducerRegistry.register<IKeyboardShortcutsState>(STORE_NAME,
enabled: false
};
case REGISTER_KEYBOARD_SHORTCUT: {
const shortcutKey = action.shortcut.alt ? `:${action.shortcut.character}` : action.shortcut.character;
const { alt, character, ctrl } = action.shortcut;
let shortcutKey = character;
if (ctrl && alt) {
shortcutKey = `-:${character}`;
} else if (alt) {
shortcutKey = `:${character}`;
} else if (ctrl) {
shortcutKey = `-${character}`;
}
return {
...state,
@@ -51,7 +60,15 @@ ReducerRegistry.register<IKeyboardShortcutsState>(STORE_NAME,
};
}
case UNREGISTER_KEYBOARD_SHORTCUT: {
const shortcutKey = action.alt ? `:${action.character}` : action.character;
let shortcutKey = action.character;
if (action.ctrl && action.alt) {
shortcutKey = `-:${action.character}`;
} else if (action.alt) {
shortcutKey = `:${action.character}`;
} else if (action.ctrl) {
shortcutKey = `-${action.character}`;
}
const shortcuts = new Map(state.shortcuts);
shortcuts.delete(shortcutKey);

View File

@@ -6,6 +6,9 @@ export interface IKeyboardShortcut {
// the character to be pressed that triggers the action
character: string;
// whether or not the ctrl key must be pressed
ctrl?: boolean;
// the function to be executed when the shortcut is pressed
handler: Function;

View File

@@ -41,6 +41,10 @@ export const getKeyboardKey = (e: KeyboardEvent): string => {
const replacedKey = code.replace('Key', '');
if (ctrlKey && altKey) {
return `-:${replacedKey}`;
}
if (altKey) {
return `:${replacedKey}`;
}

View File

@@ -614,13 +614,7 @@ function _registerForNativeEvents(store: IStore) {
const activeSession = getActiveSession(state, mode);
if (!activeSession?.id) {
logger.error('No recording or streaming session found');
return;
}
conference.stopRecording(activeSession.id);
conference.stopRecording(activeSession?.id);
});
eventEmitter.addListener(ExternalAPI.OVERWRITE_CONFIG, ({ config }: any) => {

View File

@@ -0,0 +1,4 @@
/**
* The type of the Redux action which sends page reload log.
*/
export const PAGE_RELOAD_APPLICATION_LOG = 'PAGE_RELOAD_APPLICATION_LOG';

View File

@@ -0,0 +1,14 @@
import { PAGE_RELOAD_APPLICATION_LOG } from './actionTypes';
/**
* Sends a page reload application log message.
*
* @param {string} reason - The reason for the reload.
* @returns {Object}
*/
export function sendPageReloadApplicationLog(reason?: string) {
return {
type: PAGE_RELOAD_APPLICATION_LOG,
reason
};
}

View File

@@ -11,6 +11,7 @@ import {
isFatalJitsiConferenceError,
isFatalJitsiConnectionError
} from '../../../base/lib-jitsi-meet/functions.web';
import { sendPageReloadApplicationLog } from '../../actions.any';
import logger from '../../logger';
import ReloadButton from './ReloadButton';
@@ -152,13 +153,7 @@ export default class AbstractPageReloadOverlay<P extends IProps>
* @returns {void}
*/
override componentDidMount() {
// FIXME: We should dispatch action for this.
if (typeof APP !== 'undefined' && APP.conference?._room) {
APP.conference._room.sendApplicationLog(JSON.stringify({
name: 'page.reload',
label: this.props.reason
}));
}
this.props.dispatch(sendPageReloadApplicationLog(this.props.reason));
sendAnalytics(createPageReloadScheduledEvent(
this.props.reason ?? '',

View File

@@ -1,11 +1,14 @@
import { IStore } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { JitsiConferenceErrors, JitsiConnectionErrors } from '../base/lib-jitsi-meet';
import {
isFatalJitsiConferenceError,
isFatalJitsiConnectionError
} from '../base/lib-jitsi-meet/functions.any';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { PAGE_RELOAD_APPLICATION_LOG } from './actionTypes';
import { openPageReloadDialog } from './actions';
import logger from './logger';
@@ -128,3 +131,25 @@ StateListenerRegistry.register(
}
}
);
/**
* Middleware for overlay specific actions.
*
* @param {Store} store - The redux store.
* @returns {Function}
*/
MiddlewareRegistry.register(({ getState }) => next => action => {
const result = next(action);
if (action.type === PAGE_RELOAD_APPLICATION_LOG) {
const state = getState();
const conference = getCurrentConference(state) ?? state['features/base/conference']?.leaving;
conference?.sendApplicationLog(JSON.stringify({
name: 'page.reload',
label: action.reason
}));
}
return result;
});

View File

@@ -2,6 +2,7 @@ import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { CONFERENCE_FAILED, CONFERENCE_JOINED } from '../base/conference/actionTypes';
import { SET_CONFIG } from '../base/config/actionTypes';
import { CONNECTION_FAILED } from '../base/connection/actionTypes';
import { SET_AUDIO_MUTED, SET_VIDEO_MUTED } from '../base/media/actionTypes';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
@@ -10,13 +11,14 @@ import {
TRACK_ADDED,
TRACK_NO_DATA_FROM_SOURCE
} from '../base/tracks/actionTypes';
import { preloadZxcvbn } from '../base/util/isInsecureRoomName';
import {
setDeviceStatusOk,
setDeviceStatusWarning,
setJoiningInProgress
} from './actions';
import { isPrejoinPageVisible } from './functions.any';
import { isPrejoinPageVisible, isUnsafeRoomWarningEnabled } from './functions.any';
/**
* The redux middleware for {@link PrejoinPage}.
@@ -26,6 +28,15 @@ import { isPrejoinPageVisible } from './functions.any';
*/
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case SET_CONFIG: {
const result = next(action);
if (isUnsafeRoomWarningEnabled(store.getState())) {
preloadZxcvbn();
}
return result;
}
case SET_AUDIO_MUTED: {
if (isPrejoinPageVisible(store.getState())) {
store.dispatch(updateSettings({

View File

@@ -256,14 +256,37 @@ export function showStartedRecordingNotification(
const recordingSharingUrl = getRecordingSharingUrl(state);
const iAmRecordingInitiator = getLocalParticipant(state)?.id === initiatorId;
const { showRecordingLink } = state['features/base/config'].recordings || {};
const isTranscribing = isRecorderTranscriptionsRunning(state);
const isRecording = isRecordingRunning(state);
notifyProps.dialogProps = {
customActionHandler: undefined,
customActionNameKey: undefined,
descriptionKey: participantName ? 'recording.onBy' : 'recording.on',
descriptionArguments: { name: participantName },
titleKey: 'dialog.recording'
};
// Case 1: Transcription only (no recording)
if (isTranscribing && !isRecording) {
notifyProps.dialogProps = {
customActionHandler: undefined,
customActionNameKey: undefined,
descriptionKey: participantName ? 'transcribing.onBy' : 'transcribing.on',
descriptionArguments: { name: participantName },
titleKey: 'dialog.recording'
};
} else if (isTranscribing && isRecording) {
// Case 2: Recording + transcription
notifyProps.dialogProps = {
customActionHandler: undefined,
customActionNameKey: undefined,
descriptionKey: participantName ? 'recording.onByWithTranscription' : 'recording.onWithTranscription',
descriptionArguments: { name: participantName },
titleKey: 'dialog.recording'
};
} else {
// Case 3: Recording only (no transcription)
notifyProps.dialogProps = {
customActionHandler: undefined,
customActionNameKey: undefined,
descriptionKey: participantName ? 'recording.onBy' : 'recording.on',
descriptionArguments: { name: participantName },
titleKey: 'dialog.recording'
};
}
// fetch the recording link from the server for recording initiators in jaas meetings
if (recordingSharingUrl

View File

@@ -84,8 +84,7 @@ export function _mapStateToProps(state: IReduxState, ownProps: any) {
const _isLivestreamingRunning = isLiveStreamingRunning(state);
const _isVisible = isLiveStreamingLabel
? _isLivestreamingRunning // this is the livestreaming label
: isRecordingRunning(state) || isRemoteParticipantRecordingLocally(state)
|| _isTranscribing; // this is the recording label
: isRecordingRunning(state) || isRemoteParticipantRecordingLocally(state); // this is the recording label
return {
_isVisible,

View File

@@ -57,9 +57,7 @@ export default class AbstractStopLiveStreamDialog extends Component<IProps> {
const { _session } = this.props;
if (_session) {
this.props._conference?.stopRecording(_session.id);
}
this.props._conference?.stopRecording(_session?.id);
return true;
}

View File

@@ -1,8 +1,9 @@
import React from 'react';
import { Trans } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import { translate } from '../../../../base/i18n/functions';
import { translate } from '../../../../base/i18n/functions.web';
import Dialog from '../../../../base/ui/components/web/Dialog';
import Spinner from '../../../../base/ui/components/web/Spinner';
import {
@@ -275,19 +276,14 @@ class StartLiveStreamDialog
;
}
/**
* FIXME: Ideally this help text would be one translation string
* that also accepts the anchor. This can be done using the Trans
* component of react-i18next but I couldn't get it working...
*/
helpText = (
<div>
{ `${t('liveStreaming.chooseCTA',
{ email: _googleProfileEmail })} ` }
<a onClick = { this._onRequestGoogleSignIn }>
{ t('liveStreaming.changeSignIn') }
</a>
</div>
<Trans
components = { [ <a
key = 'change-sign-in'
onClick = { this._onRequestGoogleSignIn } /> ] }
i18nKey = 'liveStreaming.chooseCTAWithChangeSignIn'
t = { t }
values = {{ email: _googleProfileEmail }} />
);
break;

View File

@@ -0,0 +1,55 @@
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import ExpandedLabel, { IProps as AbstractProps } from '../../../base/label/components/native/ExpandedLabel';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
interface IProps extends AbstractProps, WithTranslation {
/**
* Whether this meeting is being transcribed.
*/
_isTranscribing?: boolean;
}
/**
* A react {@code Component} that implements an expanded label as tooltip-like
* component to explain the meaning of the {@code TranscribingLabel}.
*/
class TranscribingExpandedLabel extends ExpandedLabel<IProps> {
/**
* Returns the label specific text of this {@code ExpandedLabel}.
*
* @returns {string}
*/
_getLabel() {
const { _isTranscribing, t } = this.props;
if (_isTranscribing) {
return t('transcribing.labelTooltip');
}
return t('transcribing.expandedOff');
}
}
/**
* Maps (parts of) the Redux state to the associated
* {@code TranscribingExpandedLabel}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _isTranscribing: boolean
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
_isTranscribing: isRecorderTranscriptionsRunning(state)
};
}
export default translate(connect(_mapStateToProps)(TranscribingExpandedLabel));

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import { IconTranscription } from '../../../base/icons/svg';
import Label from '../../../base/label/components/native/Label';
import { JitsiRecordingConstants } from '../../../base/lib-jitsi-meet';
import { StyleType } from '../../../base/styles/functions.any';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import AbstractRecordingLabel, {
IProps as AbstractProps
} from '../AbstractRecordingLabel';
import styles from './styles';
/**
* Implements a React {@link Component} which displays the current state of
* transcription.
*
* @augments {Component}
*/
class TranscribingLabel extends AbstractRecordingLabel<AbstractProps> {
/**
* Renders the platform specific label component.
*
* @inheritdoc
*/
_renderLabel() {
const { _isTranscribing } = this.props;
if (!_isTranscribing) {
return null;
}
return (
<Label
icon = { IconTranscription }
status = { 'on' }
style = { styles.transcribingIndicatorStyle as StyleType } />
);
}
}
/**
* Maps (parts of) the Redux state to the associated props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Object}
*/
function _mapStateToProps(state: any) {
const _isTranscribing = isRecorderTranscriptionsRunning(state);
return {
_isVisible: _isTranscribing,
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
_isTranscribing,
mode: 'transcribing', // Custom mode for transcription
_status: _isTranscribing ? JitsiRecordingConstants.status.ON : JitsiRecordingConstants.status.OFF
};
}
export default translate(connect(_mapStateToProps)(TranscribingLabel));

View File

@@ -14,5 +14,15 @@ export default createStyleSheet({
marginLeft: 0,
marginBottom: 0,
backgroundColor: BaseTheme.palette.iconError
},
/**
* Style for the transcription indicator.
*/
transcribingIndicatorStyle: {
marginRight: 4,
marginLeft: 0,
marginBottom: 0,
backgroundColor: BaseTheme.palette.transcriptionIndicator
}
});

View File

@@ -3,6 +3,8 @@ import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IStore } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import { IconRecord, IconSites } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
@@ -12,6 +14,7 @@ import AbstractRecordingLabel, {
IProps as AbstractProps,
_mapStateToProps
} from '../AbstractRecordingLabel';
import StopRecordingDialog from '../Recording/web/StopRecordingDialog';
interface IProps extends AbstractProps {
@@ -20,6 +23,11 @@ interface IProps extends AbstractProps {
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
}
/**
@@ -44,13 +52,33 @@ const styles = (theme: Theme) => {
* @augments {Component}
*/
class RecordingLabel extends AbstractRecordingLabel<IProps> {
/**
* Initializes a new {@code RecordingLabel} instance.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._onClick = this._onClick.bind(this);
}
/**
* Handles clicking on the label.
*
* @returns {void}
*/
_onClick() {
this.props.dispatch(openDialog('StopRecordingDialog', StopRecordingDialog));
}
/**
* Renders the platform specific label component.
*
* @inheritdoc
*/
override _renderLabel() {
const { _isTranscribing, _status, mode, t } = this.props;
const { _status, mode, t } = this.props;
const classes = withStyles.getClasses(this.props);
const isRecording = mode === JitsiRecordingConstants.mode.FILE;
const icon = isRecording ? IconRecord : IconSites;
@@ -58,14 +86,8 @@ class RecordingLabel extends AbstractRecordingLabel<IProps> {
if (_status === JitsiRecordingConstants.status.ON) {
content = t(isRecording ? 'videoStatus.recording' : 'videoStatus.streaming');
if (_isTranscribing) {
content += ` ${t('transcribing.labelTooltipExtra')}`;
}
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
return null;
} else if (_isTranscribing) {
content = t('transcribing.labelTooltip');
} else {
return null;
}
@@ -76,7 +98,8 @@ class RecordingLabel extends AbstractRecordingLabel<IProps> {
position = { 'bottom' }>
<Label
className = { classes.record }
icon = { icon } />
icon = { icon }
onClick = { this._onClick } />
</Tooltip>
);
}

View File

@@ -0,0 +1,120 @@
import { Theme } from '@mui/material';
import React from 'react';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import { IconTranscription } from '../../../base/icons/svg';
import Label from '../../../base/label/components/web/Label';
import Tooltip from '../../../base/tooltip/components/Tooltip';
import { isRecorderTranscriptionsRunning } from '../../../transcribing/functions';
import AbstractRecordingLabel, {
IProps as AbstractProps
} from '../AbstractRecordingLabel';
import StopRecordingDialog from '../Recording/web/StopRecordingDialog';
interface IProps extends AbstractProps {
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
}
/**
* Creates the styles for the component.
*
* @param {Object} theme - The current UI theme.
*
* @returns {Object}
*/
const styles = (theme: Theme) => {
return {
transcribing: {
background: theme.palette.transcriptionIndicator
}
};
};
/**
* Implements a React {@link Component} which displays the current state of
* transcription.
*
* @augments {Component}
*/
class TranscribingLabel extends AbstractRecordingLabel<IProps> {
/**
* Initializes a new {@code TranscribingLabel} instance.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._onClick = this._onClick.bind(this);
}
/**
* Handles clicking on the label.
*
* @returns {void}
*/
_onClick() {
this.props.dispatch(openDialog('StopRecordingDialog', StopRecordingDialog));
}
/**
* Renders the platform specific label component.
*
* @inheritdoc
*/
override _renderLabel() {
const { _isTranscribing, t } = this.props;
const classes = withStyles.getClasses(this.props);
if (!_isTranscribing) {
return null;
}
const content = t('transcribing.labelTooltip');
return (
<Tooltip
content = { content }
position = { 'bottom' }>
<Label
className = { classes.transcribing }
icon = { IconTranscription }
onClick = { this._onClick } />
</Tooltip>
);
}
}
/**
* Maps (parts of) the Redux state to the associated props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Object}
*/
function _mapStateToProps(state: IReduxState) {
const _isTranscribing = isRecorderTranscriptionsRunning(state);
return {
_isVisible: _isTranscribing,
_iAmRecorder: Boolean(state['features/base/config'].iAmRecorder),
_isTranscribing,
mode: 'transcribing' // Custom mode for transcription
};
}
export default withStyles(translate(connect(_mapStateToProps)(TranscribingLabel)), styles);

View File

@@ -36,6 +36,34 @@ export const RECORDING_OFF_SOUND_ID = 'RECORDING_OFF_SOUND';
*/
export const RECORDING_ON_SOUND_ID = 'RECORDING_ON_SOUND';
/**
* The identifier of the sound to be played when transcription is stopped.
*
* @type {string}
*/
export const TRANSCRIPTION_OFF_SOUND_ID = 'TRANSCRIPTION_OFF_SOUND';
/**
* The identifier of the sound to be played when transcription is started.
*
* @type {string}
*/
export const TRANSCRIPTION_ON_SOUND_ID = 'TRANSCRIPTION_ON_SOUND';
/**
* The identifier of the sound to be played when recording and transcription are stopped.
*
* @type {string}
*/
export const RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID = 'RECORDING_AND_TRANSCRIPTION_OFF_SOUND';
/**
* The identifier of the sound to be played when recording and transcription are started.
*
* @type {string}
*/
export const RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID = 'RECORDING_AND_TRANSCRIPTION_ON_SOUND';
/**
* Expected supported recording types.
*

View File

@@ -19,17 +19,25 @@ import LocalRecordingManager from './components/Recording/LocalRecordingManager'
import {
LIVE_STREAMING_OFF_SOUND_ID,
LIVE_STREAMING_ON_SOUND_ID,
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID,
RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID,
RECORDING_OFF_SOUND_ID,
RECORDING_ON_SOUND_ID,
RECORDING_STATUS_PRIORITIES,
RECORDING_TYPES
RECORDING_TYPES,
TRANSCRIPTION_OFF_SOUND_ID,
TRANSCRIPTION_ON_SOUND_ID
} from './constants';
import logger from './logger';
import {
LIVE_STREAMING_OFF_SOUND_FILE,
LIVE_STREAMING_ON_SOUND_FILE,
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE,
RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE,
RECORDING_OFF_SOUND_FILE,
RECORDING_ON_SOUND_FILE
RECORDING_ON_SOUND_FILE,
TRANSCRIPTION_OFF_SOUND_FILE,
TRANSCRIPTION_ON_SOUND_FILE
} from './sounds';
/**
@@ -384,6 +392,10 @@ export function unregisterRecordingAudioFiles(dispatch: IStore['dispatch']) {
dispatch(unregisterSound(LIVE_STREAMING_ON_SOUND_FILE));
dispatch(unregisterSound(RECORDING_OFF_SOUND_FILE));
dispatch(unregisterSound(RECORDING_ON_SOUND_FILE));
dispatch(unregisterSound(TRANSCRIPTION_OFF_SOUND_FILE));
dispatch(unregisterSound(TRANSCRIPTION_ON_SOUND_FILE));
dispatch(unregisterSound(RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE));
dispatch(unregisterSound(RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE));
}
/**
@@ -415,6 +427,22 @@ export function registerRecordingAudioFiles(dispatch: IStore['dispatch'], should
dispatch(registerSound(
RECORDING_ON_SOUND_ID,
getSoundFileSrc(RECORDING_ON_SOUND_FILE, language)));
dispatch(registerSound(
TRANSCRIPTION_OFF_SOUND_ID,
getSoundFileSrc(TRANSCRIPTION_OFF_SOUND_FILE, language)));
dispatch(registerSound(
TRANSCRIPTION_ON_SOUND_ID,
getSoundFileSrc(TRANSCRIPTION_ON_SOUND_FILE, language)));
dispatch(registerSound(
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID,
getSoundFileSrc(RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE, language)));
dispatch(registerSound(
RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID,
getSoundFileSrc(RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE, language)));
}
/**

View File

@@ -51,6 +51,8 @@ import LocalRecordingManager from './components/Recording/LocalRecordingManager'
import {
LIVE_STREAMING_OFF_SOUND_ID,
LIVE_STREAMING_ON_SOUND_ID,
RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID,
RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID,
RECORDING_OFF_SOUND_ID,
RECORDING_ON_SOUND_ID,
START_RECORDING_NOTIFICATION_ID
@@ -64,6 +66,11 @@ import {
} from './functions';
import logger from './logger';
/**
* Map to track which recording sessions have transcription enabled.
*/
const sessionsWithTranscription = new Map<string, boolean>();
/**
* StateListenerRegistry provides a reliable way to detect the leaving of a
* conference, where we need to clean up the recording sessions.
@@ -247,9 +254,23 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
sendAnalytics(createRecordingEvent('start', mode));
let soundID;
const isTranscribing = isRecorderTranscriptionsRunning(state);
const isRequestingTranscription = state['features/subtitles']._requestingSubtitles;
const willTranscribe = isTranscribing || isRequestingTranscription;
if (mode === JitsiRecordingConstants.mode.FILE && !isRecorderTranscriptionsRunning(state)) {
soundID = RECORDING_ON_SOUND_ID;
// Store whether transcription was enabled when recording started
if (mode === JitsiRecordingConstants.mode.FILE) {
const sessionId = action.sessionData.id;
if (sessionId) {
sessionsWithTranscription.set(sessionId, willTranscribe);
}
if (willTranscribe) {
soundID = RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID;
} else {
soundID = RECORDING_ON_SOUND_ID;
}
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
soundID = LIVE_STREAMING_ON_SOUND_ID;
}
@@ -264,11 +285,11 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
}
}
} else if (updatedSessionData?.status === OFF && oldSessionData?.status !== OFF) {
if (terminator) {
dispatch(
showStoppedRecordingNotification(
mode, getParticipantDisplayName(state, getResourceId(terminator))));
}
const participantName = terminator
? getParticipantDisplayName(state, getResourceId(terminator))
: undefined;
dispatch(showStoppedRecordingNotification(mode, participantName));
let duration = 0, soundOff, soundOn;
@@ -278,9 +299,23 @@ MiddlewareRegistry.register(({ dispatch, getState }) => next => action => {
}
sendAnalytics(createRecordingEvent('stop', mode, duration));
if (mode === JitsiRecordingConstants.mode.FILE && !isRecorderTranscriptionsRunning(state)) {
soundOff = RECORDING_OFF_SOUND_ID;
soundOn = RECORDING_ON_SOUND_ID;
// Check if transcription was enabled when the recording started
const sessionId = action.sessionData.id;
const wasWithTranscription = sessionId ? sessionsWithTranscription.get(sessionId) ?? false : false;
if (mode === JitsiRecordingConstants.mode.FILE) {
if (wasWithTranscription) {
soundOff = RECORDING_AND_TRANSCRIPTION_OFF_SOUND_ID;
soundOn = RECORDING_AND_TRANSCRIPTION_ON_SOUND_ID;
} else {
soundOff = RECORDING_OFF_SOUND_ID;
soundOn = RECORDING_ON_SOUND_ID;
}
// Clean up the entry when recording stops
if (sessionId) {
sessionsWithTranscription.delete(sessionId);
}
} else if (mode === JitsiRecordingConstants.mode.STREAM) {
soundOff = LIVE_STREAMING_OFF_SOUND_ID;
soundOn = LIVE_STREAMING_ON_SOUND_ID;

View File

@@ -25,3 +25,31 @@ export const RECORDING_OFF_SOUND_FILE = 'recordingOff.mp3';
* @type {string}
*/
export const RECORDING_ON_SOUND_FILE = 'recordingOn.mp3';
/**
* The name of the bundled audio file which will be played for when transcription is stopped.
*
* @type {string}
*/
export const TRANSCRIPTION_OFF_SOUND_FILE = 'transcriptionOff.mp3';
/**
* The name of the bundled audio file which will be played for when transcription is started.
*
* @type {string}
*/
export const TRANSCRIPTION_ON_SOUND_FILE = 'transcriptionOn.mp3';
/**
* The name of the bundled audio file which will be played for when recording and transcription are stopped.
*
* @type {string}
*/
export const RECORDING_AND_TRANSCRIPTION_OFF_SOUND_FILE = 'recordingAndTranscriptionOff.mp3';
/**
* The name of the bundled audio file which will be played for when recording and transcription are started.
*
* @type {string}
*/
export const RECORDING_AND_TRANSCRIPTION_ON_SOUND_FILE = 'recordingAndTranscriptionOn.mp3';

View File

@@ -0,0 +1,313 @@
import { IConfig } from '../../base/config/configType';
import logger from '../../virtual-background/logger';
export enum BackendType {
TFLITE = 'tflite',
WEBGL = 'webgl',
WEBGPU = 'webgpu'
}
export enum DeviceTier {
HIGH = 'high',
LOW = 'low',
MEDIUM = 'medium',
UNSUPPORTED = 'unsupported'
}
export enum ModelType {
GENERAL = 'general',
LANDSCAPE = 'landscape'
}
export interface IDeviceCapabilities {
backend: BackendType;
modelType: ModelType;
overridesApplied: boolean;
reason: string;
segHeight: number;
segWidth: number;
targetFps: number;
tier: DeviceTier;
}
interface ITierProfile {
backend: BackendType;
modelType: ModelType;
segHeight: number;
segWidth: number;
targetFps: number;
}
/**
* Shape persisted to localStorage. Only the hardware-detected tier and its reason are stored;
* config overrides are never cached so they can be changed without clearing storage.
*/
interface ITierCacheEntry {
reason: string;
tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
version: number;
}
const TIER_PROFILES: Record<Exclude<DeviceTier, DeviceTier.UNSUPPORTED>, ITierProfile> = {
[DeviceTier.HIGH]: {
backend: BackendType.WEBGPU,
modelType: ModelType.LANDSCAPE,
segHeight: 288,
segWidth: 512,
targetFps: 30
},
[DeviceTier.LOW]: {
backend: BackendType.TFLITE,
modelType: ModelType.LANDSCAPE,
segHeight: 256,
segWidth: 256,
targetFps: 30
},
[DeviceTier.MEDIUM]: {
backend: BackendType.WEBGL,
modelType: ModelType.LANDSCAPE,
segHeight: 216,
segWidth: 384,
targetFps: 30
}
};
const TIER_CACHE_KEY = 'jitsi_vb_device_tier';
/**
* Bump this when the probing logic changes in a way that may yield a different result on the same hardware. Stale
* entries with an older version are discarded and a fresh probe is run.
*/
const TIER_CACHE_VERSION = 1;
/**
* Returns the cached hardware tier, or null if the cache is absent or stale.
*
* @returns {ITierCacheEntry | null}
*/
function readTierCache(): ITierCacheEntry | null {
try {
const raw = localStorage.getItem(TIER_CACHE_KEY);
if (!raw) {
return null;
}
const entry: ITierCacheEntry = JSON.parse(raw);
if (entry?.version !== TIER_CACHE_VERSION) {
return null;
}
const validTiers: string[] = [ DeviceTier.HIGH, DeviceTier.MEDIUM, DeviceTier.LOW ];
if (!validTiers.includes(entry.tier)) {
return null;
}
return entry;
} catch {
return null;
}
}
/**
* Persists the hardware-detected tier to localStorage.
*
* @param {Exclude<DeviceTier, DeviceTier.UNSUPPORTED>} tier - Detected tier.
* @param {string} reason - Human-readable detection reason.
* @returns {void}
*/
function writeTierCache(tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>, reason: string): void {
try {
const entry: ITierCacheEntry = { reason, tier, version: TIER_CACHE_VERSION };
localStorage.setItem(TIER_CACHE_KEY, JSON.stringify(entry));
} catch {
// localStorage may be unavailable (private browsing, storage quota exceeded). Non-fatal.
}
}
/**
* Runs the hardware capability probes (WebGPU → WebGL → WASM) and returns the detected tier and a human-readable
* reason string. This is the expensive part that we want to run only once.
*
* Returns null when no usable backend is found on this device (no WebGPU, WebGL, or WebAssembly).
*
* @returns {Promise<Object|null>} Resolves with a tier/reason pair, or null if no backend is available.
*/
async function probeHardwareTier(): Promise<{
reason: string;
tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
} | null> {
let tier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED> | DeviceTier.UNSUPPORTED = DeviceTier.UNSUPPORTED;
let reason = '';
// --- High tier: WebGPU ---
try {
if ((navigator as any).gpu) {
const adapter = await (navigator as any).gpu.requestAdapter();
if (adapter) {
const adapterName = (adapter as any).name ?? 'unknown GPU';
logger.debug(`[VirtualBackground] WebGPU: available (adapter: "${adapterName}")`);
tier = DeviceTier.HIGH;
reason = 'WebGPU adapter found; high-end GPU path selected';
} else {
logger.debug('[VirtualBackground] WebGPU: unavailable (adapter request returned null)');
}
}
} catch {
logger.debug('[VirtualBackground] WebGPU: unavailable (requestAdapter threw)');
}
// --- Medium tier: WebGL 2 / WebGL 1 ---
// Only probed when WebGPU was not found. Each context is released immediately via WEBGL_lose_context
// so it does not count against the browser's concurrent WebGL context limit (~16 in Chrome).
if (tier === DeviceTier.UNSUPPORTED) {
const testCanvas = document.createElement('canvas');
const gl2 = testCanvas.getContext('webgl2');
if (gl2) {
gl2.getExtension('WEBGL_lose_context')?.loseContext();
logger.debug('[VirtualBackground] WebGL2: available');
tier = DeviceTier.MEDIUM;
reason = 'WebGL2 available; standard path selected';
} else {
logger.debug('[VirtualBackground] WebGL2: unavailable (context creation failed)');
// webgl2 returned null — the canvas is still context-free; try webgl1.
const gl1 = testCanvas.getContext('webgl');
if (gl1) {
gl1.getExtension('WEBGL_lose_context')?.loseContext();
logger.debug('[VirtualBackground] WebGL1: available');
tier = DeviceTier.MEDIUM;
reason = 'WebGL1 available; standard path selected (WebGL2 unavailable)';
} else {
logger.debug('[VirtualBackground] WebGL1: unavailable (context creation failed)');
}
}
}
// --- Low tier: WASM ---
if (tier === DeviceTier.UNSUPPORTED) {
if (typeof WebAssembly !== 'undefined') {
logger.debug('[VirtualBackground] WASM: available');
tier = DeviceTier.LOW;
reason = 'No GPU context available; WASM CPU fallback';
} else {
logger.debug('[VirtualBackground] WASM: unavailable');
return null;
}
}
return { reason, tier: tier as Exclude<DeviceTier, DeviceTier.UNSUPPORTED> };
}
/**
* Returns the device capabilities for virtual background V2 processing.
*
* Hardware detection (WebGPU → WebGL → WASM probe) runs once and the result is written to localStorage.
* Every subsequent call reads the cached tier and skips re-probing — this avoids an async WebGPU adapter request and
* redundant WebGL context creation on every effect start. Config overrides (tierOverride, segmentationWidth, etc.) are
* applied on top of the cached tier on every call and are never persisted.
*
* @param {IConfig} config - The app config object.
* @returns {Promise<IDeviceCapabilities>}
*/
export async function detectDeviceTier(config: IConfig): Promise<IDeviceCapabilities> {
let hardwareTier: Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
let reason: string;
const cached = readTierCache();
if (cached) {
hardwareTier = cached.tier;
reason = cached.reason;
logger.debug(`[VirtualBackground] Hardware tier (cached): ${hardwareTier}${reason}`);
} else {
logger.debug('[VirtualBackground] No cached tier — probing hardware capabilities');
const probeResult = await probeHardwareTier();
if (!probeResult) {
return {
backend: BackendType.TFLITE,
modelType: ModelType.LANDSCAPE,
overridesApplied: false,
reason: 'No WebGPU, WebGL, or WASM available — virtual background disabled',
segHeight: 0,
segWidth: 0,
targetFps: 0,
tier: DeviceTier.UNSUPPORTED
};
}
hardwareTier = probeResult.tier as Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
reason = probeResult.reason;
writeTierCache(hardwareTier, reason);
logger.info(
`[VirtualBackground] Hardware tier: ${hardwareTier}${reason} (written to cache)`);
}
// Build capabilities from the hardware tier profile.
const profile = TIER_PROFILES[hardwareTier];
const caps: IDeviceCapabilities = {
...profile,
overridesApplied: false,
reason,
tier: hardwareTier
};
logger.debug(
`[VirtualBackground] → Tier: ${caps.tier} | Backend: ${caps.backend} | `
+ `Seg: ${caps.segWidth}×${caps.segHeight} | FPS: ${caps.targetFps} | Model: ${caps.modelType}`);
// Apply config overrides on every call — never cached.
const vbConfig = config.virtualBackground;
let anyOverride = false;
if (vbConfig) {
if (vbConfig.tierOverride && vbConfig.tierOverride !== caps.tier) {
const overrideTier = vbConfig.tierOverride as Exclude<DeviceTier, DeviceTier.UNSUPPORTED>;
const overrideProfile = TIER_PROFILES[overrideTier];
if (overrideProfile) {
caps.tier = overrideTier;
caps.backend = overrideProfile.backend;
caps.segHeight = overrideProfile.segHeight;
caps.segWidth = overrideProfile.segWidth;
caps.targetFps = overrideProfile.targetFps;
caps.modelType = overrideProfile.modelType;
logger.debug(`[VirtualBackground] Config override: tier forced to ${overrideTier}`);
anyOverride = true;
}
}
if (typeof vbConfig.segmentationWidth === 'number') {
caps.segWidth = vbConfig.segmentationWidth;
logger.debug(`[VirtualBackground] Config override: segmentationWidth = ${caps.segWidth}`);
anyOverride = true;
}
if (typeof vbConfig.segmentationHeight === 'number') {
caps.segHeight = vbConfig.segmentationHeight;
logger.debug(`[VirtualBackground] Config override: segmentationHeight = ${caps.segHeight}`);
anyOverride = true;
}
if (typeof vbConfig.targetFps === 'number') {
caps.targetFps = vbConfig.targetFps;
logger.debug(`[VirtualBackground] Config override: targetFps = ${caps.targetFps}`);
anyOverride = true;
}
}
caps.overridesApplied = anyOverride;
return caps;
}

View File

@@ -12,6 +12,17 @@ export interface IBackgroundEffectOptions {
virtualBackground: {
backgroundType?: string;
blurValue?: number;
studioLightOptions?: {
bgDimming?: number;
brightness?: number;
contrast?: number;
glowIntensity?: number;
saturation?: number;
skinSmoothing?: number;
toneB?: number;
toneG?: number;
toneR?: number;
};
virtualSource?: string;
};
width: number;
@@ -235,7 +246,7 @@ export default class JitsiStreamBackgroundEffect {
this._segmentationMaskCanvas = document.createElement('canvas');
this._segmentationMaskCanvas.width = this._options.width;
this._segmentationMaskCanvas.height = this._options.height;
this._segmentationMaskCtx = this._segmentationMaskCanvas.getContext('2d');
this._segmentationMaskCtx = this._segmentationMaskCanvas.getContext('2d', { willReadFrequently: true });
this._outputCanvasElement.width = parseInt(width, 10);
this._outputCanvasElement.height = parseInt(height, 10);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,484 @@
/**
* Virtual Background V2 — inference worker.
*
* Handles TF.js body-segmentation (MEDIUM/HIGH tiers via WebGL/WebGPU) and TFLite WASM
* (LOW tier via selfie_segmenter FP16) in a single dedicated Web Worker. Running
* all inference in a Worker keeps the main thread free for UI events. For GPU tiers, the
* Worker's OffscreenCanvas-backed WebGL context is not subject to Chrome's tab-visibility
* GPU scheduling throttle.
*/
/*
* Message protocol
*
* Main -> Worker:
* { type: 'init', backend: string, modelType: string, segWidth: number, segHeight: number,
* tfliteModelPath: string, tfliteWasmBase: string }
* { type: 'infer', bitmap: ImageBitmap } (bitmap is transferred -- zero-copy)
* { type: 'stop' }
*
* Worker -> Main:
* { type: 'init_done', backend: string, segHeight: number, segWidth: number }
* backend/segHeight/segWidth reflect the actual tier used (may differ from requested
* backend when the Worker fell back to TFLite because the GPU backend was unavailable)
* { type: 'init_error', error: string }
* { type: 'mask', data: Uint8ClampedArray, width: number, height: number }
* (data.buffer is transferred -- zero-copy)
* { type: 'infer_error', error: string }
*/
import '@tensorflow/tfjs-backend-webgl';
import '@tensorflow/tfjs-backend-webgpu';
import * as tf from '@tensorflow/tfjs-core';
import type { BodySegmenter, MediaPipeSelfieSegmentationTfjsModelConfig }
from '@tensorflow-models/body-segmentation';
import * as bs from '@tensorflow-models/body-segmentation';
import { BackendType } from './DeviceTierDetector.web';
/* eslint-disable lines-around-comment */
// @ts-ignore
import createTFLiteModule from './vendor/tflite/tflite';
// @ts-ignore
import createTFLiteSIMDModule from './vendor/tflite/tflite-simd';
/* eslint-enable lines-around-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/** Maximum segmentation dimension in pixels. Guards against OOM via crafted init messages. */
const MAX_SEG_DIMENSION = 2048;
/**
* Extracts the effective origin of this Worker script.
*
* Workers loaded via a blob URL have self.location.href of the form
* "blob:https://hostname/uuid". Standard URL parsing returns origin="null" for blob URLs,
* so we strip the "blob:" prefix and parse the embedded URL to recover the real origin.
*
* @returns {string} The origin (e.g. "https://meet.example.com"), or empty string if unparseable.
*/
function getWorkerOrigin(): string {
try {
const { href } = self.location;
return new URL(href.startsWith('blob:') ? href.slice(5) : href).origin;
} catch {
return '';
}
}
/**
* Returns true when {@code url} is a valid http/https URL at the same origin as this Worker.
* Rejects data URIs, javascript: URLs, file: URLs, and cross-origin paths.
*
* @param {string} url - URL to validate.
* @returns {boolean}
*/
function isSameOriginUrl(url: string): boolean {
try {
const parsed = new URL(url);
return (parsed.protocol === 'https:' || parsed.protocol === 'http:')
&& parsed.origin === getWorkerOrigin();
} catch {
return false;
}
}
/** Active TF.js segmenter (MEDIUM/HIGH tiers). Null when using TFLite backend. */
let segmenter: BodySegmenter | null = null;
/** Active TFLite module (LOW tier, TFLITE backend). Null when using TF.js. */
let tfliteModule: any = null;
/** TFLite input HEAPF32 offset (byte offset / 4). Pre-computed after model load. */
let tfliteInputOffset = 0;
/** TFLite output HEAPF32 offset (byte offset / 4). Pre-computed after model load. */
let tfliteOutputOffset = 0;
/** TFLite segmentation pixel count (segWidth x segHeight). */
let tflitePixelCount = 0;
/** TFLite segmentation canvas width. */
let tfliteSegWidth = 0;
/** TFLite segmentation canvas height. */
let tfliteSegHeight = 0;
/** OffscreenCanvas for downscaling frames before TFLite inference. */
let tfliteReadCanvas: any = null;
/** 2D context for tfliteReadCanvas. */
let tfliteReadCtx: any = null;
/** Active backend type. TFLITE selects TFLite WASM; WEBGL/WEBGPU selects TF.js. */
let currentBackend: BackendType | '' = '';
/**
* Type-safe postMessage wrapper for DedicatedWorkerGlobalScope.
* TypeScript types the global 'self' as Window (lib.dom) which has a different postMessage
* signature to DedicatedWorkerGlobalScope. Casting through 'any' bypasses the mismatch.
*
* @param {any} msg - Structured-cloneable message payload.
* @param {Transferable[]} [transfer] - Optional list of transferable objects (zero-copy).
* @returns {void}
*/
function workerPost(msg: any, transfer?: Transferable[]): void {
if (transfer?.length) {
(self as any).postMessage(msg, transfer);
} else {
(self as any).postMessage(msg);
}
}
// onmessage at module scope — runs in DedicatedWorkerGlobalScope after importScripts loads this bundle.
// TypeScript types 'onmessage' as Window.onmessage, but the runtime target is the worker global.
(self as any).onmessage = async (e: MessageEvent) => {
const { type } = e.data;
if (type === 'init') {
await handleInit(e.data);
} else if (type === 'infer') {
await handleInfer(e.data.bitmap as ImageBitmap);
} else if (type === 'stop') {
handleStop();
}
};
/**
* Initialises the worker backend based on the tier signalled by the main thread.
*
* Routes to the TFLite handler for LOW tier (backend 'tflite') or the TF.js handler for
* MEDIUM/HIGH tiers (backend 'webgl' or 'webgpu'). The TFLite model path and WASM base are
* always passed so the TF.js handler can fall back to TFLite if GPU init fails in the Worker.
*
* @param {Object} data - Init message payload.
* @param {string} data.backend - 'tflite', 'webgl', or 'webgpu'.
* @param {string} data.modelType - 'general' or 'landscape' (TF.js tiers only).
* @param {number} data.segHeight - Segmentation canvas height.
* @param {number} data.segWidth - Segmentation canvas width.
* @param {string} data.tfliteModelPath - Absolute URL of the TFLite segmentation model.
* @param {string} data.tfliteWasmBase - Base URL for tflite*.wasm binaries (trailing slash).
* @returns {Promise<void>}
*/
async function handleInit(data: {
backend: BackendType;
modelType: string;
segHeight: number;
segWidth: number;
tfliteModelPath: string;
tfliteWasmBase: string;
}): Promise<void> {
// Whitelist backend — rejects unknown values before they reach tf.setBackend() or TFLite.
if (data.backend !== BackendType.TFLITE
&& data.backend !== BackendType.WEBGL
&& data.backend !== BackendType.WEBGPU) {
workerPost({ error: `Unknown backend: "${data.backend}"`, type: 'init_error' });
return;
}
// Bounds-check dimensions — prevents OOM from crafted init messages.
if (!Number.isInteger(data.segWidth) || data.segWidth < 1 || data.segWidth > MAX_SEG_DIMENSION
|| !Number.isInteger(data.segHeight) || data.segHeight < 1
|| data.segHeight > MAX_SEG_DIMENSION) {
workerPost({ error: 'Invalid segmentation dimensions in init message', type: 'init_error' });
return;
}
currentBackend = data.backend;
if (data.backend === BackendType.TFLITE) {
await handleInitTflite(data);
} else {
await handleInitTfjs(data);
}
}
/**
* Initialises TF.js with the tier-appropriate backend and creates the body-segmentation model.
* In a Worker, TF.js automatically uses OffscreenCanvas for its WebGL context (document is
* unavailable). Chrome does not apply tab-visibility GPU throttling to Worker GPU contexts.
*
* If the requested GPU backend is unavailable (no GPU, OffscreenCanvas WebGL unsupported, etc.),
* falls back to TFLite WASM using the TFLite model path supplied in the init message. This covers
* servers and VMs where the main-thread tier detector passes GPU checks (software rasteriser) but
* the Worker's OffscreenCanvas context creation fails.
*
* @param {Object} data - Init message payload.
* @param {string} data.backend - 'webgl' (MEDIUM tier) or 'webgpu' (HIGH tier).
* @param {string} data.modelType - 'general' or 'landscape'.
* @param {number} data.segWidth - Unused here; inference at bitmap resolution.
* @param {number} data.segHeight - Unused here; inference at bitmap resolution.
* @param {string} data.tfliteModelPath - TFLite model URL used if GPU fallback is triggered.
* @param {string} data.tfliteWasmBase - TFLite WASM base URL used if GPU fallback is triggered.
* @returns {Promise<void>}
*/
async function handleInitTfjs(data: {
backend: string;
modelType: string;
segHeight: number;
segWidth: number;
tfliteModelPath: string;
tfliteWasmBase: string;
}): Promise<void> {
const { backend, modelType, tfliteModelPath, tfliteWasmBase } = data;
try {
const backendSet = await tf.setBackend(backend);
await tf.ready();
if (!backendSet || tf.getBackend() !== backend) {
// GPU backend unavailable in this Worker context — fall back to TFLite.
currentBackend = BackendType.TFLITE;
await handleInitTflite({ segHeight: 256, segWidth: 256, tfliteModelPath, tfliteWasmBase });
return;
}
segmenter = await bs.createSegmenter(
bs.SupportedModels.MediaPipeSelfieSegmentation,
{ modelType, runtime: 'tfjs' } as MediaPipeSelfieSegmentationTfjsModelConfig
);
workerPost({ backend: currentBackend, segHeight: data.segHeight, segWidth: data.segWidth, type: 'init_done' });
} catch (err) {
// GPU init threw (e.g. OffscreenCanvas WebGL context creation failed) — fall back to TFLite.
currentBackend = BackendType.TFLITE;
try {
await handleInitTflite({ segHeight: 256, segWidth: 256, tfliteModelPath, tfliteWasmBase });
} catch (tfliteErr) {
workerPost({ error: String(tfliteErr), type: 'init_error' });
}
}
}
/**
* Returns true when the runtime supports WebAssembly SIMD instructions.
*
* Uses WebAssembly.validate with a minimal module containing a v128.const SIMD instruction.
* Returns false on any error (older browsers, WASM disabled, etc.).
*
* @returns {boolean}
*/
function detectSimd(): boolean {
try {
return WebAssembly.validate(new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123,
3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0, 253, 15, 253, 98, 11
]));
} catch {
return false;
}
}
/**
* Initialises the TFLite WASM backend for LOW tier using the selfie_segmenter model.
*
* Loads the appropriate WASM runtime (SIMD or standard) using the createTFLiteModule /
* createTFLiteSIMDModule factory with a locateFile override so the .wasm binary is resolved
* from the deployment libs/ directory rather than from the (unusable) blob: worker origin.
* Fetches the .tflite model, writes it to the TFLite HEAPU8 buffer, and calls _loadModel().
* Pre-computes HEAPF32 input/output offsets and creates an OffscreenCanvas for pixel readback.
*
* @param {Object} data - Init message payload for the TFLITE tier.
* @param {string} data.modelPath - Absolute URL of the TFLite segmentation model.
* @param {number} data.segHeight - Segmentation canvas height.
* @param {number} data.segWidth - Segmentation canvas width.
* @param {string} data.tfliteWasmBase - Base URL for tflite*.wasm binaries (trailing slash).
* @returns {Promise<void>}
*/
async function handleInitTflite(data: {
segHeight: number;
segWidth: number;
tfliteModelPath: string;
tfliteWasmBase: string;
}): Promise<void> {
try {
const { segHeight, segWidth, tfliteModelPath, tfliteWasmBase } = data;
if (!isSameOriginUrl(tfliteModelPath) || !isSameOriginUrl(`${tfliteWasmBase}tflite.wasm`)) {
workerPost({ error: 'TFLite model or WASM path must be same-origin', type: 'init_error' });
return;
}
// Select SIMD-optimised runtime when available — roughly 2x faster than standard WASM.
const simdSupported = detectSimd();
const tfliteFactory = simdSupported ? createTFLiteSIMDModule : createTFLiteModule;
// locateFile overrides WASM path resolution — required because the worker runs from a
// blob: URL whose origin is "null", making relative path resolution fail.
tfliteModule = await tfliteFactory({
locateFile: (path: string) => `${tfliteWasmBase}${path}`
});
// Fetch and load model into TFLite WASM heap.
const modelResponse = await fetch(tfliteModelPath, { credentials: 'same-origin' });
if (!modelResponse.ok) {
workerPost({ error: `TFLite model fetch failed: HTTP ${modelResponse.status}`, type: 'init_error' });
return;
}
const modelBuffer = await modelResponse.arrayBuffer();
tfliteModule.HEAPU8.set(new Uint8Array(modelBuffer), tfliteModule._getModelBufferMemoryOffset());
tfliteModule._loadModel(modelBuffer.byteLength);
// Pre-compute HEAPF32 offsets to avoid divide-by-4 on every frame.
tfliteInputOffset = tfliteModule._getInputMemoryOffset() / 4;
tfliteOutputOffset = tfliteModule._getOutputMemoryOffset() / 4;
tfliteSegWidth = segWidth;
tfliteSegHeight = segHeight;
tflitePixelCount = segWidth * segHeight;
tfliteReadCanvas = new OffscreenCanvas(segWidth, segHeight);
tfliteReadCtx = tfliteReadCanvas.getContext('2d', { willReadFrequently: true });
workerPost({ backend: currentBackend, segHeight: tfliteSegHeight, segWidth: tfliteSegWidth, type: 'init_done' });
} catch (err) {
workerPost({ error: String(err), type: 'init_error' });
}
}
/**
* Dispatches an inference call to the TFLite or TF.js handler based on the active backend.
*
* @param {ImageBitmap} bitmap - Pre-scaled camera frame at the tier's segmentation resolution.
* @returns {Promise<void>}
*/
async function handleInfer(bitmap: ImageBitmap): Promise<void> {
if (currentBackend === BackendType.TFLITE) {
await handleInferTflite(bitmap);
} else {
await handleInferTfjs(bitmap);
}
}
/**
* Runs one TF.js inference cycle on the transferred ImageBitmap.
*
* The bitmap is at the tier's segmentation resolution (pre-scaled on the main thread via
* createImageBitmap resize options). TF.js skips its own internal resize, reducing per-frame cost.
* The raw mask Uint8ClampedArray is transferred back (zero-copy) for EMA smoothing and WebGL
* compositing on the main thread.
*
* @param {ImageBitmap} bitmap - Pre-scaled camera frame at seg resolution.
* @returns {Promise<void>}
*/
async function handleInferTfjs(bitmap: ImageBitmap): Promise<void> {
if (!segmenter) {
bitmap.close();
workerPost({ error: 'Segmenter not initialised', type: 'infer_error' });
return;
}
tf.engine().startScope();
try {
const segmentations = await segmenter.segmentPeople(
bitmap as unknown as HTMLImageElement, { flipHorizontal: false }
);
if (!segmentations.length) {
workerPost({ error: 'No segmentation result', type: 'infer_error' });
return;
}
const maskData = await segmentations[0].mask.toImageData();
const data = maskData.data;
// Transfer the backing ArrayBuffer — zero-copy, data becomes detached in this worker.
workerPost(
{ data, height: maskData.height, type: 'mask', width: maskData.width },
[ data.buffer as ArrayBuffer ]
);
} catch (err) {
workerPost({ error: String(err), type: 'infer_error' });
} finally {
// Close unconditionally — called in finally so the bitmap is always released
// even when segmentPeople() throws before the try-block close is reached.
bitmap.close();
tf.engine().endScope();
}
}
/**
* Runs one TFLite inference cycle on the transferred ImageBitmap.
*
* Draws the bitmap to an OffscreenCanvas, fills the TFLite HEAPF32 input buffer with NHWC
* float32 RGB values normalised to [0,1], calls _runInference(), and reads the single-channel
* output (selfie_segmenter outputs one float per pixel: person confidence in [0,1]).
* Writes the confidence value to both R and A channels of the returned Uint8ClampedArray so
* it is compatible with both the WebGL compositor (reads .r) and the Canvas 2D fallback (reads alpha).
*
* @param {ImageBitmap} bitmap - Pre-scaled camera frame at seg resolution.
* @returns {Promise<void>}
*/
async function handleInferTflite(bitmap: ImageBitmap): Promise<void> {
if (!tfliteModule || !tfliteReadCtx) {
bitmap.close();
workerPost({ error: 'TFLite not initialised', type: 'infer_error' });
return;
}
try {
// Draw pre-scaled bitmap to the read canvas and extract RGBA pixels.
tfliteReadCtx.drawImage(bitmap, 0, 0, tfliteSegWidth, tfliteSegHeight);
const imageData = tfliteReadCtx.getImageData(0, 0, tfliteSegWidth, tfliteSegHeight);
// Fill NHWC float32 input [1, H, W, 3] with RGB channels normalised to [0, 1].
const pixelCount = tflitePixelCount;
for (let i = 0; i < pixelCount; i++) {
tfliteModule.HEAPF32[tfliteInputOffset + i * 3] = imageData.data[i * 4] / 255;
tfliteModule.HEAPF32[tfliteInputOffset + i * 3 + 1] = imageData.data[i * 4 + 1] / 255;
tfliteModule.HEAPF32[tfliteInputOffset + i * 3 + 2] = imageData.data[i * 4 + 2] / 255;
}
// Run TFLite inference (synchronous WASM call).
tfliteModule._runInference();
// Read single-channel output: one float per pixel (person confidence in [0, 1]).
// selfie_segmenter outputs probabilities directly — no softmax needed.
const maskBytes = new Uint8ClampedArray(pixelCount * 4);
for (let i = 0; i < pixelCount; i++) {
const v = Math.round(tfliteModule.HEAPF32[tfliteOutputOffset + i] * 255);
maskBytes[i * 4] = v; // R — read by WebGL compositor shader (.r channel)
maskBytes[i * 4 + 3] = v; // A — read by Canvas 2D fallback compositing path
}
workerPost(
{ data: maskBytes, height: tfliteSegHeight, type: 'mask', width: tfliteSegWidth },
[ maskBytes.buffer ]
);
} catch (err) {
workerPost({ error: String(err), type: 'infer_error' });
} finally {
bitmap.close();
}
}
/**
* Disposes all backend resources and resets state.
*
* @returns {void}
*/
function handleStop(): void {
segmenter?.dispose();
segmenter = null;
// TFLite: module has no explicit dispose API; null refs allow GC.
tfliteModule = null;
tfliteReadCanvas = null;
tfliteReadCtx = null;
currentBackend = '';
}

View File

@@ -0,0 +1,726 @@
import logger from '../../virtual-background/logger';
const VERTEX_SHADER_SOURCE = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
`;
// GLSL ES 1.0 — compatible with both WebGL 1 and WebGL 2.
const VB_FRAGMENT_SHADER_SOURCE = `
precision mediump float;
uniform sampler2D u_camera;
uniform sampler2D u_background;
uniform sampler2D u_mask;
uniform sampler2D u_prevMask;
uniform float u_temporalRatio;
uniform float u_edgeLow;
uniform float u_edgeHigh;
uniform vec2 u_maskTexelSize;
uniform vec2 u_cameraTexelSize;
varying vec2 v_texCoord;
// 3x3 Gaussian blur (sigma≈1) sampled in mask-texture space.
// When u_maskTexelSize is (0,0) all taps collapse to tc — weights sum to 1.0, no blur.
float sampleMaskBlurred(sampler2D tex, vec2 tc) {
vec2 t = u_maskTexelSize;
return
texture2D(tex, tc + vec2(-t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, -t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2(-t.x, 0.0)).r * 0.125 +
texture2D(tex, tc ).r * 0.25 +
texture2D(tex, tc + vec2( t.x, 0.0)).r * 0.125 +
texture2D(tex, tc + vec2(-t.x, t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, t.y)).r * 0.0625;
}
// Luminance gradient magnitude at this pixel using a simple cross-pattern sample.
// Returns values in roughly [0, 0.5] — strong camera edges (e.g. shirt vs chair) hit 0.2+.
float cameraEdgeStrength(vec2 tc) {
vec2 t = u_cameraTexelSize;
vec3 lv = vec3(0.299, 0.587, 0.114);
float lL = dot(texture2D(u_camera, tc - vec2(t.x, 0.0)).rgb, lv);
float lR = dot(texture2D(u_camera, tc + vec2(t.x, 0.0)).rgb, lv);
float lU = dot(texture2D(u_camera, tc - vec2(0.0, t.y)).rgb, lv);
float lD = dot(texture2D(u_camera, tc + vec2(0.0, t.y)).rgb, lv);
return length(vec2(lR - lL, lD - lU));
}
void main() {
vec4 fg = texture2D(u_camera, v_texCoord);
vec4 bg = texture2D(u_background, v_texCoord);
// Blurred mask — wide feathering for smooth regions (hair, soft edges).
float curBlurred = sampleMaskBlurred(u_mask, v_texCoord);
float prevBlurred = sampleMaskBlurred(u_prevMask, v_texCoord);
// Raw (unblurred) mask — used to snap the edge at sharp camera boundaries.
float curRaw = texture2D(u_mask, v_texCoord).r;
float prevRaw = texture2D(u_prevMask, v_texCoord).r;
// At strong camera color edges (e.g. dark chair vs bright shirt) snap toward the raw mask
// so the person boundary aligns with the actual image edge. At smooth regions (hair, skin)
// keep the blurred mask for natural feathering.
float snapT = smoothstep(0.05, 0.20, cameraEdgeStrength(v_texCoord));
float curAlpha = mix(curBlurred, curRaw, snapT);
float prevAlpha = mix(prevBlurred, prevRaw, snapT);
float blended = mix(curAlpha, prevAlpha, u_temporalRatio);
float alpha = smoothstep(u_edgeLow, u_edgeHigh, blended);
gl_FragColor = mix(bg, fg, alpha);
}
`;
// Studio light fragment shader — applies brightness, contrast, skin smoothing,
// tone correction and edge glow to the person region identified by the mask.
const STUDIO_LIGHT_FRAGMENT_SHADER_SOURCE = `
precision mediump float;
uniform sampler2D u_camera;
uniform sampler2D u_mask;
uniform sampler2D u_prevMask;
uniform float u_temporalRatio;
uniform float u_edgeLow;
uniform float u_edgeHigh;
uniform vec2 u_maskTexelSize;
uniform float u_brightness;
uniform float u_contrast;
uniform float u_glowIntensity;
uniform float u_saturation;
uniform float u_skinSmoothing;
uniform vec3 u_toneRGB;
uniform vec2 u_cameraTexelSize;
uniform float u_bgDimming;
varying vec2 v_texCoord;
float sampleMask(sampler2D tex, vec2 tc) {
vec2 t = u_maskTexelSize;
// When maskTexelSize is (0,0) all taps collapse — no blur, crisp edges.
return
texture2D(tex, tc + vec2(-t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, -t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, -t.y)).r * 0.0625 +
texture2D(tex, tc + vec2(-t.x, 0.0)).r * 0.125 +
texture2D(tex, tc ).r * 0.25 +
texture2D(tex, tc + vec2( t.x, 0.0)).r * 0.125 +
texture2D(tex, tc + vec2(-t.x, t.y)).r * 0.0625 +
texture2D(tex, tc + vec2( 0.0, t.y)).r * 0.125 +
texture2D(tex, tc + vec2( t.x, t.y)).r * 0.0625;
}
vec4 sampleCameraBlurred(vec2 tc) {
vec2 t = u_cameraTexelSize * 2.0;
vec4 s = vec4(0.0);
s += texture2D(u_camera, tc + vec2(-2.0*t.x, -2.0*t.y)) * 0.003;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, -2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 0.0, -2.0*t.y)) * 0.022;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, -2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, -2.0*t.y)) * 0.003;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, -1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, -1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 0.0, -1.0*t.y)) * 0.098;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, -1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, -1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, 0.0 )) * 0.022;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, 0.0 )) * 0.098;
s += texture2D(u_camera, tc ) * 0.162;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, 0.0 )) * 0.098;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, 0.0 )) * 0.022;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, 1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, 1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 0.0, 1.0*t.y)) * 0.098;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, 1.0*t.y)) * 0.060;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, 1.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2(-2.0*t.x, 2.0*t.y)) * 0.003;
s += texture2D(u_camera, tc + vec2(-1.0*t.x, 2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 0.0, 2.0*t.y)) * 0.022;
s += texture2D(u_camera, tc + vec2( 1.0*t.x, 2.0*t.y)) * 0.013;
s += texture2D(u_camera, tc + vec2( 2.0*t.x, 2.0*t.y)) * 0.003;
return s;
}
void main() {
vec4 original = texture2D(u_camera, v_texCoord);
float curAlpha = sampleMask(u_mask, v_texCoord);
float prevAlpha = sampleMask(u_prevMask, v_texCoord);
float blended = mix(curAlpha, prevAlpha, u_temporalRatio);
float alpha = smoothstep(u_edgeLow, u_edgeHigh, blended);
// 1. Skin smoothing: blend original with blurred version within mask.
vec4 blurred = sampleCameraBlurred(v_texCoord);
vec4 smoothed = mix(original, blurred, u_skinSmoothing * alpha);
// 2. Brightness and contrast.
vec3 adjusted = (smoothed.rgb - 0.5) * u_contrast + 0.5 + u_brightness;
adjusted = clamp(adjusted, 0.0, 1.0);
// 3. Tone correction (RGB gains).
adjusted *= u_toneRGB;
adjusted = clamp(adjusted, 0.0, 1.0);
// 4. Saturation: shift toward/away from luminance.
float luma = dot(adjusted, vec3(0.2126, 0.7152, 0.0722));
adjusted = mix(vec3(luma), adjusted, u_saturation);
adjusted = clamp(adjusted, 0.0, 1.0);
// 5. Edge glow: peaks at mask boundary where alpha*(1-alpha) is max.
float edgeMask = alpha * (1.0 - alpha) * 4.0;
adjusted += adjusted * u_glowIntensity * edgeMask;
adjusted = clamp(adjusted, 0.0, 1.0);
// Dim background proportional to how far outside the person mask.
vec3 bg = original.rgb * (1.0 - u_bgDimming * (1.0 - alpha));
// Mix: person region gets studio-lit version, background dimmed.
gl_FragColor = vec4(mix(bg, adjusted, alpha), 1.0);
}
`;
// Full-screen quad: two triangles covering clip space [-1,1].
const QUAD_VERTICES = new Float32Array([
-1, -1, 0, 1,
1, -1, 1, 1,
-1, 1, 0, 0,
1, 1, 1, 0
]);
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (!shader) {
throw new Error('[VirtualBackground] WebGLCompositor: createShader returned null');
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`[VirtualBackground] WebGLCompositor: shader compile error — ${info}`);
}
return shader;
}
function createProgram(gl: WebGLRenderingContext, fragmentSource: string): WebGLProgram {
const vert = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE);
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
const program = gl.createProgram();
if (!program) {
throw new Error('[VirtualBackground] WebGLCompositor: createProgram returned null');
}
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
throw new Error(`[VirtualBackground] WebGLCompositor: program link error — ${info}`);
}
gl.deleteShader(vert);
gl.deleteShader(frag);
return program;
}
function createTexture(gl: WebGLRenderingContext): WebGLTexture {
const tex = gl.createTexture();
if (!tex) {
throw new Error('[VirtualBackground] WebGLCompositor: createTexture returned null');
}
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
return tex;
}
/**
* WebGL-based compositor that blends camera, background, and segmentation mask
* using a fragment shader with temporal smoothing and edge feathering (smoothstep).
*
* Falls back gracefully when context creation fails — callers should check
* {@link WebGLCompositor#isAvailable} before use.
*/
export default class WebGLCompositor {
_canvas: HTMLCanvasElement;
_gl: WebGLRenderingContext | null = null;
// Bound event handlers — stored so they can be removed in dispose().
_onContextLostBound: ((event: Event) => void) | null = null;
_onContextRestoredBound: (() => void) | null = null;
_program: WebGLProgram | null = null;
_quadBuffer: WebGLBuffer | null = null;
_texBackground: WebGLTexture | null = null;
_texCamera: WebGLTexture | null = null;
_texMask: WebGLTexture | null = null;
_texMaskB: WebGLTexture | null = null;
// Attribute locations — resolved once after program link, reused every frame.
// When sharing a GL context with TF.js the vertex attribute state (buffer
// binding + vertexAttribPointer) is global and gets overwritten by TF.js
// inference. Re-binding before drawArrays restores our state without the
// overhead of re-querying locations each frame.
_aPosition = -1;
_aTexCoord = -1;
// Uniform locations — VB program, resolved once after program link.
_uBackground: WebGLUniformLocation | null = null;
_uCamera: WebGLUniformLocation | null = null;
_uCameraTexelSize: WebGLUniformLocation | null = null;
_uEdgeHigh: WebGLUniformLocation | null = null;
_uEdgeLow: WebGLUniformLocation | null = null;
_uMask: WebGLUniformLocation | null = null;
_uMaskTexelSize: WebGLUniformLocation | null = null;
_uPrevMask: WebGLUniformLocation | null = null;
_uTemporalRatio: WebGLUniformLocation | null = null;
// Studio light program and uniform locations.
_studioProgram: WebGLProgram | null = null;
_uStBgDimming: WebGLUniformLocation | null = null;
_uStBrightness: WebGLUniformLocation | null = null;
_uStCamera: WebGLUniformLocation | null = null;
_uStCameraTexelSize: WebGLUniformLocation | null = null;
_uStContrast: WebGLUniformLocation | null = null;
_uStEdgeHigh: WebGLUniformLocation | null = null;
_uStEdgeLow: WebGLUniformLocation | null = null;
_uStGlowIntensity: WebGLUniformLocation | null = null;
_uStMask: WebGLUniformLocation | null = null;
_uStMaskTexelSize: WebGLUniformLocation | null = null;
_uStPrevMask: WebGLUniformLocation | null = null;
_uStSaturation: WebGLUniformLocation | null = null;
_uStSkinSmoothing: WebGLUniformLocation | null = null;
_uStTemporalRatio: WebGLUniformLocation | null = null;
_uStToneRGB: WebGLUniformLocation | null = null;
/**
* Creates a WebGLCompositor targeting the given canvas.
*
* @param {HTMLCanvasElement} canvas - Output canvas element.
*/
constructor(canvas: HTMLCanvasElement) {
this._canvas = canvas;
this._init();
this._onContextLostBound = this._onContextLost.bind(this);
this._onContextRestoredBound = this._onContextRestored.bind(this);
canvas.addEventListener('webglcontextlost', this._onContextLostBound, false);
canvas.addEventListener('webglcontextrestored', this._onContextRestoredBound, false);
}
/**
* Returns true if a WebGL context is active and ready.
*
* @returns {boolean}
*/
get isAvailable(): boolean {
return this._gl !== null && !this._gl.isContextLost();
}
/**
* Composites using raw {@code ImageData} for the segmentation mask.
*
* Identical to {@link compositeWithSource} in every way except the mask is
* uploaded via the {@code Uint8Array} form of {@code texImage2D} rather than
* via the {@code CanvasImageSource} form. This is critical when the mask data
* comes from {@code Mask.toImageData()} because the {@code CanvasImageSource}
* upload path reads from the browser's internal canvas storage which uses
* <em>premultiplied alpha</em>: for this model the R and A channels are both
* equal to the segmentation confidence, so the canvas stores
* {@code R = confidence²} instead of {@code confidence}. The raw upload path
* bypasses this distortion entirely.
*
* Temporal ping-pong is still managed GPU-side via {@code _texMask} /
* {@code _texMaskB} — no CPU-side mask copy is needed.
*
* @param {TexImageSource} cameraSource - Live camera frame (HTMLVideoElement, ImageBitmap, VideoFrame, etc.).
* @param {HTMLCanvasElement} backgroundSource - Pre-rendered background canvas.
* @param {ImageData} maskData - Current-frame segmentation mask (raw bytes).
* @param {number} temporalRatio - Blend weight toward previous mask.
* @param {number} edgeLow - Smoothstep lower threshold.
* @param {number} edgeHigh - Smoothstep upper threshold.
* @param {boolean} isFirstFrame - When true, seeds the previous-mask texture
* from the current mask so the first frame blends against itself.
* @param {number} maskBlurRadius - Gaussian blur radius in mask-texture pixels (0 = no blur).
* Pass 4 for TF.js body-segmentation (near-binary output needs softening); 0 for ORT/TFLite.
* @returns {void}
*/
compositeFromImageData(
cameraSource: TexImageSource,
backgroundSource: HTMLCanvasElement,
maskData: ImageData,
temporalRatio: number,
edgeLow: number,
edgeHigh: number,
isFirstFrame: boolean,
maskBlurRadius = 0
): void {
const gl = this._gl;
if (!gl || !this._program) {
return;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this._canvas.width, this._canvas.height);
gl.useProgram(this._program);
// Camera texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._texCamera);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, cameraSource);
gl.uniform1i(this._uCamera, 0);
// Background texture
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this._texBackground);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, backgroundSource);
gl.uniform1i(this._uBackground, 1);
// Current mask — raw Uint8Array upload bypasses browser premultiplied-alpha
// conversion that would corrupt the R channel when uploading via canvas.
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this._texMask);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
gl.uniform1i(this._uMask, 2);
// Previous mask — GPU ping-pong via _texMaskB.
// On the first frame, seed from the current mask so the blend starts
// with a consistent state.
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this._texMaskB);
if (isFirstFrame) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
}
gl.uniform1i(this._uPrevMask, 3);
gl.uniform1f(this._uTemporalRatio, temporalRatio);
gl.uniform1f(this._uEdgeLow, edgeLow);
gl.uniform1f(this._uEdgeHigh, edgeHigh);
gl.uniform2f(this._uMaskTexelSize, maskBlurRadius / maskData.width, maskBlurRadius / maskData.height);
gl.uniform2f(this._uCameraTexelSize, 1.0 / this._canvas.width, 1.0 / this._canvas.height);
this._bindQuadState(gl);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// GPU ping-pong: swap _texMask ↔ _texMaskB.
const tmp = this._texMask;
this._texMask = this._texMaskB;
this._texMaskB = tmp;
}
/**
* Composites the camera frame with studio light effects applied to the person region.
*
* @param {TexImageSource} cameraSource - Live camera frame.
* @param {ImageData} maskData - Current-frame segmentation mask.
* @param {number} temporalRatio - Blend weight toward previous mask.
* @param {number} edgeLow - Smoothstep lower threshold.
* @param {number} edgeHigh - Smoothstep upper threshold.
* @param {boolean} isFirstFrame - Seeds previous-mask from current on first frame.
* @param {number} maskBlurRadius - Gaussian blur radius in mask-texture pixels.
* @param {number} brightness - Brightness offset.
* @param {number} contrast - Contrast multiplier.
* @param {number} skinSmoothing - Skin smoothing blend factor (0-1).
* @param {number} glowIntensity - Edge glow intensity.
* @param {number} toneR - Red channel gain.
* @param {number} toneG - Green channel gain.
* @param {number} toneB - Blue channel gain.
* @param {number} saturation - Color saturation (1.0 = normal, greater is more vivid, less is muted).
* @param {number} bgDimming - Background dimming factor (0.0 = none, 1.0 = fully black).
* @returns {void}
*/
compositeStudioLight(
cameraSource: TexImageSource,
maskData: ImageData,
temporalRatio: number,
edgeLow: number,
edgeHigh: number,
isFirstFrame: boolean,
maskBlurRadius: number,
brightness: number,
contrast: number,
skinSmoothing: number,
glowIntensity: number,
toneR: number,
toneG: number,
toneB: number,
saturation: number,
bgDimming = 0
): void {
const gl = this._gl;
if (!gl || !this._studioProgram) {
return;
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this._canvas.width, this._canvas.height);
gl.useProgram(this._studioProgram);
// Camera texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this._texCamera);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, cameraSource);
gl.uniform1i(this._uStCamera, 0);
// Current mask — raw Uint8Array upload bypasses premultiplied-alpha.
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this._texMask);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
gl.uniform1i(this._uStMask, 1);
// Previous mask — GPU ping-pong via _texMaskB.
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, this._texMaskB);
if (isFirstFrame) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, maskData.width, maskData.height,
0, gl.RGBA, gl.UNSIGNED_BYTE, maskData.data);
}
gl.uniform1i(this._uStPrevMask, 2);
// Mask uniforms
gl.uniform1f(this._uStTemporalRatio, temporalRatio);
gl.uniform1f(this._uStEdgeLow, edgeLow);
gl.uniform1f(this._uStEdgeHigh, edgeHigh);
gl.uniform2f(this._uStMaskTexelSize,
maskBlurRadius / maskData.width, maskBlurRadius / maskData.height);
// Studio light uniforms
gl.uniform1f(this._uStBrightness, brightness);
gl.uniform1f(this._uStContrast, contrast);
gl.uniform1f(this._uStSaturation, saturation);
gl.uniform1f(this._uStSkinSmoothing, skinSmoothing);
gl.uniform1f(this._uStGlowIntensity, glowIntensity);
gl.uniform3f(this._uStToneRGB, toneR, toneG, toneB);
gl.uniform1f(this._uStBgDimming, bgDimming);
gl.uniform2f(this._uStCameraTexelSize,
1.0 / this._canvas.width, 1.0 / this._canvas.height);
this._bindQuadState(gl);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// GPU ping-pong: swap _texMask and _texMaskB.
const tmp = this._texMask;
this._texMask = this._texMaskB;
this._texMaskB = tmp;
}
/**
* Returns true if the studio light shader program compiled successfully.
*
* @returns {boolean}
*/
get isStudioLightAvailable(): boolean {
return this._studioProgram !== null && this.isAvailable;
}
/**
* Releases all WebGL resources.
*
* @returns {void}
*/
dispose(): void {
// Remove event listeners first so the canvas no longer holds a reference
// to this compositor via the bound handler closures.
if (this._onContextLostBound) {
this._canvas.removeEventListener('webglcontextlost', this._onContextLostBound);
this._onContextLostBound = null;
}
if (this._onContextRestoredBound) {
this._canvas.removeEventListener('webglcontextrestored', this._onContextRestoredBound);
this._onContextRestoredBound = null;
}
const gl = this._gl;
if (!gl) {
return;
}
gl.deleteTexture(this._texCamera);
gl.deleteTexture(this._texBackground);
gl.deleteTexture(this._texMask);
gl.deleteTexture(this._texMaskB);
gl.deleteBuffer(this._quadBuffer);
gl.deleteProgram(this._program);
gl.deleteProgram(this._studioProgram);
this._texCamera = null;
this._texBackground = null;
this._texMask = null;
this._texMaskB = null;
this._quadBuffer = null;
this._program = null;
this._studioProgram = null;
// Explicitly release the WebGL context so it no longer counts against the browser's
// ~16 concurrent context limit. Without this the context stays alive until GC.
gl.getExtension('WEBGL_lose_context')?.loseContext();
this._gl = null;
}
/**
* Initialises (or re-initialises) the WebGL context, shaders, and textures.
*
* @private
* @returns {void}
*/
_init(): void {
const gl = (this._canvas.getContext('webgl2')
?? this._canvas.getContext('webgl')) as WebGLRenderingContext | null;
if (!gl) {
logger.warn('[VirtualBackground] WebGLCompositor: no WebGL context available — falling back to Canvas 2D');
return;
}
this._gl = gl;
try {
this._program = createProgram(gl, VB_FRAGMENT_SHADER_SOURCE);
} catch (err) {
logger.error('[VirtualBackground] WebGLCompositor: VB shader build failed', err);
this._gl = null;
return;
}
try {
this._studioProgram = createProgram(gl, STUDIO_LIGHT_FRAGMENT_SHADER_SOURCE);
} catch (err) {
logger.warn('[VirtualBackground] WebGLCompositor: studio light shader build failed', err);
// Non-fatal — VB still works. Studio light compositing will fall back to Canvas 2D.
}
// Vertex buffer — shared position + texCoord interleaved
this._quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, QUAD_VERTICES, gl.STATIC_DRAW);
this._aPosition = gl.getAttribLocation(this._program, 'a_position');
this._aTexCoord = gl.getAttribLocation(this._program, 'a_texCoord');
// Initial attribute setup — _bindQuadState() repeats this before every
// draw call so TF.js cannot permanently corrupt our attribute state.
this._bindQuadState(gl);
// Resolve VB uniform locations.
gl.useProgram(this._program);
this._uCamera = gl.getUniformLocation(this._program, 'u_camera');
this._uBackground = gl.getUniformLocation(this._program, 'u_background');
this._uCameraTexelSize = gl.getUniformLocation(this._program, 'u_cameraTexelSize');
this._uMask = gl.getUniformLocation(this._program, 'u_mask');
this._uMaskTexelSize = gl.getUniformLocation(this._program, 'u_maskTexelSize');
this._uPrevMask = gl.getUniformLocation(this._program, 'u_prevMask');
this._uTemporalRatio = gl.getUniformLocation(this._program, 'u_temporalRatio');
this._uEdgeLow = gl.getUniformLocation(this._program, 'u_edgeLow');
this._uEdgeHigh = gl.getUniformLocation(this._program, 'u_edgeHigh');
// Resolve studio light uniform locations.
if (this._studioProgram) {
gl.useProgram(this._studioProgram);
this._uStCamera = gl.getUniformLocation(this._studioProgram, 'u_camera');
this._uStMask = gl.getUniformLocation(this._studioProgram, 'u_mask');
this._uStPrevMask = gl.getUniformLocation(this._studioProgram, 'u_prevMask');
this._uStTemporalRatio = gl.getUniformLocation(this._studioProgram, 'u_temporalRatio');
this._uStEdgeLow = gl.getUniformLocation(this._studioProgram, 'u_edgeLow');
this._uStEdgeHigh = gl.getUniformLocation(this._studioProgram, 'u_edgeHigh');
this._uStMaskTexelSize = gl.getUniformLocation(this._studioProgram, 'u_maskTexelSize');
this._uStBgDimming = gl.getUniformLocation(this._studioProgram, 'u_bgDimming');
this._uStBrightness = gl.getUniformLocation(this._studioProgram, 'u_brightness');
this._uStContrast = gl.getUniformLocation(this._studioProgram, 'u_contrast');
this._uStGlowIntensity = gl.getUniformLocation(this._studioProgram, 'u_glowIntensity');
this._uStSaturation = gl.getUniformLocation(this._studioProgram, 'u_saturation');
this._uStSkinSmoothing = gl.getUniformLocation(this._studioProgram, 'u_skinSmoothing');
this._uStToneRGB = gl.getUniformLocation(this._studioProgram, 'u_toneRGB');
this._uStCameraTexelSize = gl.getUniformLocation(this._studioProgram, 'u_cameraTexelSize');
}
// Allocate textures
this._texCamera = createTexture(gl);
this._texBackground = createTexture(gl);
this._texMask = createTexture(gl);
this._texMaskB = createTexture(gl);
logger.info('[VirtualBackground] WebGLCompositor: initialised');
}
/**
* Handles WebGL context loss.
*
* @param {Event} event - The webglcontextlost event.
* @private
* @returns {void}
*/
_onContextLost(event: Event): void {
event.preventDefault();
logger.warn('[VirtualBackground] WebGLCompositor: context lost');
// The browser automatically destroys all GL objects on context loss.
// Null the JS references so the GC can collect the wrapper objects.
this._gl = null;
this._program = null;
this._studioProgram = null;
this._quadBuffer = null;
this._texCamera = null;
this._texBackground = null;
this._texMask = null;
this._texMaskB = null;
}
/**
* Handles WebGL context restoration.
*
* @private
* @returns {void}
*/
_onContextRestored(): void {
logger.info('[VirtualBackground] WebGLCompositor: context restored — reinitialising');
this._init();
}
/**
* Re-binds the quad vertex buffer and re-specifies vertex attribute pointers.
*
* When the GL context is shared with TF.js, TF.js inference overwrites the
* global vertex attribute state (bound ARRAY_BUFFER + vertexAttribPointer
* descriptors). Calling this before every drawArrays() restores our state
* without re-querying attribute locations.
*
* @private
* @param {WebGLRenderingContext} gl - Active GL context.
* @returns {void}
*/
_bindQuadState(gl: WebGLRenderingContext): void {
const stride = 4 * Float32Array.BYTES_PER_ELEMENT;
gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuffer);
gl.enableVertexAttribArray(this._aPosition);
gl.vertexAttribPointer(this._aPosition, 2, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(this._aTexCoord);
gl.vertexAttribPointer(this._aTexCoord, 2, gl.FLOAT, false, stride, 2 * Float32Array.BYTES_PER_ELEMENT);
}
}

View File

@@ -5,15 +5,18 @@ import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { timeout } from '../../virtual-background/functions';
import logger from '../../virtual-background/logger';
import { DeviceTier, detectDeviceTier } from './DeviceTierDetector.web';
import JitsiStreamBackgroundEffect, { IBackgroundEffectOptions } from './JitsiStreamBackgroundEffect';
import JitsiStreamBackgroundEffectV2 from './JitsiStreamBackgroundEffectV2';
// @ts-ignore
import createTFLiteModule from './vendor/tflite/tflite';
// @ts-ignore
import createTFLiteSIMDModule from './vendor/tflite/tflite-simd';
/* eslint-enable lines-around-comment */
const models = {
modelLandscape: 'libs/selfie_segmentation_landscape.tflite'
};
/* eslint-enable lines-around-comment */
let modelBuffer: ArrayBuffer;
let tflite: any;
@@ -28,16 +31,16 @@ const segmentationDimensions = {
};
/**
* Creates a new instance of JitsiStreamBackgroundEffect. This loads the Meet background model that is used to
* Creates a new V1 instance of JitsiStreamBackgroundEffect. This loads the Meet background model that is used to
* extract person segmentation.
*
* @param {Object} virtualBackground - The virtual object that contains the background image source and
* the isVirtualBackground flag that indicates if virtual image is activated.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Promise<JitsiStreamBackgroundEffect>}
* @returns {Promise<JitsiStreamBackgroundEffect | undefined>}
*/
export async function createVirtualBackgroundEffect(virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']) {
async function createVirtualBackgroundEffectV1(virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']): Promise<JitsiStreamBackgroundEffect | undefined> {
if (!MediaStreamTrack.prototype.getSettings && !MediaStreamTrack.prototype.getConstraints) {
throw new Error('JitsiStreamBackgroundEffect not supported!');
}
@@ -104,3 +107,72 @@ export async function createVirtualBackgroundEffect(virtualBackground: IBackgrou
return new JitsiStreamBackgroundEffect(tflite, options);
}
/**
* Creates a new V2 instance of the virtual background effect.
*
* Device tier detection runs first and selects the appropriate backend. All tiers run inference
* inside a dedicated VBInferenceWorker: LOW tier uses TFLite WASM (selfie_segmenter FP16) and MEDIUM/HIGH
* tiers use TF.js with WebGL/WebGPU backends. Pre-detected capabilities are passed to the
* constructor so {@link JitsiStreamBackgroundEffectV2._initAsync} does not need to re-probe.
*
* @param {Object} virtualBackground - The virtual background options.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Promise<JitsiStreamBackgroundEffectV2 | undefined>}
*/
async function createVirtualBackgroundEffectV2(
virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']
): Promise<JitsiStreamBackgroundEffect | JitsiStreamBackgroundEffectV2 | undefined> {
const config = APP.store.getState()['features/base/config'];
let capabilities;
try {
capabilities = await detectDeviceTier(config);
} catch (err) {
logger.error('[VirtualBackground] Device tier detection failed', err);
dispatch?.(showWarningNotification({
titleKey: 'virtualBackground.backgroundEffectError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
return;
}
if (capabilities.tier === DeviceTier.UNSUPPORTED) {
logger.warn('[VirtualBackground] No supported GPU or WASM backend — virtual background unavailable');
dispatch?.(showWarningNotification({
titleKey: 'virtualBackground.backgroundEffectError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
return;
}
return new JitsiStreamBackgroundEffectV2(virtualBackground, config, capabilities);
}
/**
* Creates a new instance of the virtual background stream effect. Delegates to V1 (TFLite WASM) or
* V2 (MediaPipe body-segmentation + WebGL) based on the {@code virtualBackground.enableV2} config flag.
*
* @param {Object} virtualBackground - The virtual object that contains the background image source and
* the isVirtualBackground flag that indicates if virtual image is activated.
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Promise<JitsiStreamBackgroundEffect | JitsiStreamBackgroundEffectV2 | undefined>}
*/
export async function createVirtualBackgroundEffect(
virtualBackground: IBackgroundEffectOptions['virtualBackground'],
dispatch?: IStore['dispatch']
): Promise<JitsiStreamBackgroundEffect | JitsiStreamBackgroundEffectV2 | undefined> {
const config = APP.store.getState()['features/base/config'];
if (config.virtualBackground?.enableV2) {
logger.info('[VirtualBackground] Using V2 engine (MediaPipe body-segmentation + WebGL)');
return createVirtualBackgroundEffectV2(virtualBackground, dispatch);
}
logger.info('[VirtualBackground] Using V1 engine (TFLite WASM)');
return createVirtualBackgroundEffectV1(virtualBackground, dispatch);
}

View File

@@ -7,7 +7,10 @@ import { playSound } from '../base/sounds/actions';
import { showNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { INotificationProps } from '../notifications/types';
import { RECORDING_OFF_SOUND_ID, RECORDING_ON_SOUND_ID } from '../recording/constants';
import {
TRANSCRIPTION_OFF_SOUND_ID,
TRANSCRIPTION_ON_SOUND_ID
} from '../recording/constants';
import { isLiveStreamingRunning, isRecordingRunning } from '../recording/functions';
import { isRecorderTranscriptionsRunning, isTranscribing } from './functions';
@@ -17,7 +20,12 @@ import { isRecorderTranscriptionsRunning, isTranscribing } from './functions';
*/
StateListenerRegistry.register(
/* selector */ isRecorderTranscriptionsRunning,
/* listener */ (isRecorderTranscriptionsRunningValue, { getState, dispatch }) => {
/* listener */ (isRecorderTranscriptionsRunningValue, { getState, dispatch }, previousValue) => {
// Only emit notifications on actual changes, not on initial state
if (previousValue === undefined) {
return;
}
if (isRecorderTranscriptionsRunningValue) {
maybeEmitRecordingNotification(dispatch, getState, true);
} else {
@@ -27,7 +35,12 @@ StateListenerRegistry.register(
);
StateListenerRegistry.register(
/* selector */ isTranscribing,
/* listener */ (isTranscribingValue, { getState }) => {
/* listener */ (isTranscribingValue, { getState }, previousValue) => {
// Only notify on actual changes, not on initial state
if (previousValue === undefined) {
return;
}
if (isTranscribingValue) {
notifyTranscribingStatusChanged(getState, true);
} else {
@@ -56,14 +69,15 @@ function maybeEmitRecordingNotification(dispatch: IStore['dispatch'], getState:
return;
}
// Show transcription-specific notification when there's no recording
const notifyProps: INotificationProps = {
descriptionKey: on ? 'recording.on' : 'recording.off',
descriptionKey: on ? 'transcribing.on' : 'transcribing.off',
titleKey: 'dialog.recording'
};
batch(() => {
dispatch(showNotification(notifyProps, NOTIFICATION_TIMEOUT_TYPE.SHORT));
dispatch(playSound(on ? RECORDING_ON_SOUND_ID : RECORDING_OFF_SOUND_ID));
dispatch(playSound(on ? TRANSCRIPTION_ON_SOUND_ID : TRANSCRIPTION_OFF_SOUND_ID));
});
}

View File

@@ -1,8 +1,10 @@
import { IStore } from '../app/types';
import { showWarningNotification } from '../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { createVirtualBackgroundEffect } from '../stream-effects/virtual-background';
import { BACKGROUND_ENABLED, SET_VIRTUAL_BACKGROUND } from './actionTypes';
import { VIRTUAL_BACKGROUND_TYPE } from './constants';
import { STUDIO_LIGHT_PRESETS, VIRTUAL_BACKGROUND_TYPE } from './constants';
import logger from './logger';
import { IVirtualBackground } from './reducer';
@@ -15,6 +17,9 @@ import { IVirtualBackground } from './reducer';
*/
export function toggleBackgroundEffect(options: IVirtualBackground, jitsiTrack: any) {
return async function(dispatch: IStore['dispatch'], getState: IStore['getState']) {
// Snapshot state before mutations so we can restore it if the effect fails to init.
const prevBackground = getState()['features/virtual-background'];
dispatch(backgroundEnabled(options.backgroundEffectEnabled));
dispatch(setVirtualBackground(options));
const state = getState();
@@ -23,14 +28,39 @@ export function toggleBackgroundEffect(options: IVirtualBackground, jitsiTrack:
if (jitsiTrack) {
try {
if (options.backgroundEffectEnabled) {
await jitsiTrack.setEffect(await createVirtualBackgroundEffect(virtualBackground, dispatch));
const effect = await createVirtualBackgroundEffect(virtualBackground, dispatch);
await jitsiTrack.setEffect(effect);
// V2 effects initialise asynchronously (worker spawn + model load). Await the
// init promise so failures propagate here instead of being silently swallowed.
if (effect && (effect as any).initPromise instanceof Promise) {
await (effect as any).initPromise;
}
} else {
await jitsiTrack.setEffect(undefined);
dispatch(backgroundEnabled(false));
}
} catch (error) {
dispatch(backgroundEnabled(false));
logger.error('Error on apply background effect:', error);
// Revert Redux state to the values that were active before the failed toggle.
dispatch(backgroundEnabled(prevBackground.backgroundEffectEnabled));
dispatch(setVirtualBackground(prevBackground));
// Remove any partially-applied effect so the video track is restored.
if (options.backgroundEffectEnabled) {
try {
await jitsiTrack.setEffect(undefined);
} catch (cleanupErr) {
logger.warn('[VirtualBackground] Failed to clear effect after init failure:', cleanupErr);
}
}
dispatch(showWarningNotification(
{ titleKey: 'virtualBackground.backgroundEffectError' },
NOTIFICATION_TIMEOUT_TYPE.LONG
));
}
}
};
@@ -53,7 +83,8 @@ export function setVirtualBackground(options?: IVirtualBackground) {
virtualSource: options?.virtualSource,
blurValue: options?.blurValue,
backgroundType: options?.backgroundType,
selectedThumbnail: options?.selectedThumbnail
selectedThumbnail: options?.selectedThumbnail,
studioLightOptions: options?.studioLightOptions
};
}
@@ -103,3 +134,36 @@ export function toggleBlurredBackgroundEffect(videoTrack: any, blurType: 'slight
}
};
}
/**
* Toggles studio light effect on the given video track. Used by the external API.
*
* @param {Object} videoTrack - The targeted video track.
* @param {boolean} enabled - Whether to enable or disable studio light.
* @param {string} [preset] - Preset name ('natural', 'spotlight', 'soft-focus'). Defaults to 'natural'.
* @returns {Function}
*/
export function toggleStudioLightEffect(videoTrack: any, enabled: boolean, preset?: string) {
return async function(dispatch: IStore['dispatch']) {
if (!videoTrack) {
return;
}
if (!enabled) {
dispatch(toggleBackgroundEffect({
backgroundEffectEnabled: false,
selectedThumbnail: 'none'
}, videoTrack));
} else {
const presetKey = preset ?? 'natural';
const presetOptions = STUDIO_LIGHT_PRESETS[presetKey] ?? STUDIO_LIGHT_PRESETS.natural;
dispatch(toggleBackgroundEffect({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.STUDIO_LIGHT,
selectedThumbnail: `studio-light-${presetKey}`,
studioLightOptions: presetOptions
}, videoTrack));
}
};
}

View File

@@ -16,7 +16,14 @@ import Tooltip from '../../base/tooltip/components/Tooltip';
import Spinner from '../../base/ui/components/web/Spinner';
import { showWarningNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { BACKGROUNDS_LIMIT, IMAGES, type Image, VIRTUAL_BACKGROUND_TYPE } from '../constants';
import {
BACKGROUNDS_LIMIT,
IMAGES,
type Image,
STUDIO_LIGHT_PRESETS,
StudioLightPreset,
VIRTUAL_BACKGROUND_TYPE
} from '../constants';
import { toDataURL } from '../functions';
import logger from '../logger';
import { IVirtualBackground } from '../reducer';
@@ -27,6 +34,11 @@ import VirtualBackgroundPreview from './VirtualBackgroundPreview';
interface IProps extends WithTranslation {
/**
* Whether studio light presets should be shown (V2 enabled).
*/
_enableStudioLight: boolean;
/**
* The list of Images to choose from.
*/
@@ -171,6 +183,36 @@ const useStyles = makeStyles()(theme => {
[[ '&:hover', '&:focus' ] as any]: {
display: 'block'
}
},
studioLightSection: {
marginTop: theme.spacing(2),
width: '100%'
},
studioLightLabel: {
...theme.typography.labelBold,
color: theme.palette.text01,
marginBottom: theme.spacing(1)
},
studioLightGrid: {
width: '100%',
display: 'inline-grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: theme.spacing(1)
},
studioLightNatural: {
background: 'linear-gradient(135deg, #f5f5f5, #e0e0e0)'
},
studioLightSpotlight: {
background: 'radial-gradient(circle at 50% 40%, #e0e0e0 0%, #2a2a2a 70%)'
},
studioLightSoftFocus: {
background: 'linear-gradient(135deg, #fce4ec, #f8bbd0, #ffffff)'
}
};
});
@@ -181,6 +223,7 @@ const useStyles = makeStyles()(theme => {
* @returns {ReactElement}
*/
function VirtualBackgrounds({
_enableStudioLight,
_images,
_showUploadButton,
onOptionsChange,
@@ -335,11 +378,59 @@ function VirtualBackgrounds({
await setPreviewIsLoaded(loaded);
}, []);
const _applyStudioLightPreset = useCallback((preset: string) => {
const presetOptions = STUDIO_LIGHT_PRESETS[preset] ?? STUDIO_LIGHT_PRESETS.natural;
onOptionsChange({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.STUDIO_LIGHT,
selectedThumbnail: `studio-light-${preset}`,
studioLightOptions: presetOptions
});
logger.info(`Studio light preset "${preset}" set for virtual background preview!`);
}, []);
const selectStudioNatural = useCallback(() => {
_applyStudioLightPreset(StudioLightPreset.NATURAL);
}, [ _applyStudioLightPreset ]);
const selectStudioNaturalKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
selectStudioNatural();
}
}, [ selectStudioNatural ]);
const selectStudioSpotlight = useCallback(() => {
_applyStudioLightPreset(StudioLightPreset.SPOTLIGHT);
}, [ _applyStudioLightPreset ]);
const selectStudioSpotlightKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
selectStudioSpotlight();
}
}, [ selectStudioSpotlight ]);
const selectStudioSoftFocus = useCallback(() => {
_applyStudioLightPreset(StudioLightPreset.SOFT_FOCUS);
}, [ _applyStudioLightPreset ]);
const selectStudioSoftFocusKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
selectStudioSoftFocus();
}
}, [ selectStudioSoftFocus ]);
// create a full list of {backgroundId: backgroundLabel} to easily retrieve label of selected background
const labelsMap: Record<string, string> = {
none: t('virtualBackground.none'),
'slight-blur': t('virtualBackground.slightBlur'),
blur: t('virtualBackground.blur'),
'studio-light-natural': t('virtualBackground.studioLightNatural'),
'studio-light-spotlight': t('virtualBackground.studioLightSpotlight'),
'studio-light-soft-focus': t('virtualBackground.studioLightSoftFocus'),
..._images.reduce<Record<string, string>>((acc, image) => {
acc[image.id] = image.tooltip ? t(`virtualBackground.${image.tooltip}`) : '';
@@ -483,6 +574,66 @@ function VirtualBackgrounds({
</div>
))}
</div>
{_enableStudioLight && (
<div className = { classes.studioLightSection }>
<div className = { classes.studioLightLabel }>
{t('virtualBackground.studioLightTitle')}
</div>
<div
aria-label = { t('virtualBackground.studioLightTitle') }
className = { classes.studioLightGrid }
role = 'radiogroup'>
<Tooltip
content = { t('virtualBackground.studioLightNatural') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('studio-light-natural') }
aria-label = { t('virtualBackground.studioLightNatural') }
className = { cx(classes.thumbnail,
classes.studioLightNatural,
getSelectedThumbnailClass('studio-light-natural')) }
onClick = { selectStudioNatural }
onKeyPress = { selectStudioNaturalKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.studioLightNatural')}
</div>
</Tooltip>
<Tooltip
content = { t('virtualBackground.studioLightSpotlight') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('studio-light-spotlight') }
aria-label = { t('virtualBackground.studioLightSpotlight') }
className = { cx(classes.thumbnail,
classes.studioLightSpotlight,
getSelectedThumbnailClass('studio-light-spotlight')) }
onClick = { selectStudioSpotlight }
onKeyPress = { selectStudioSpotlightKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.studioLightSpotlight')}
</div>
</Tooltip>
<Tooltip
content = { t('virtualBackground.studioLightSoftFocus') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('studio-light-soft-focus') }
aria-label = { t('virtualBackground.studioLightSoftFocus') }
className = { cx(classes.thumbnail,
classes.studioLightSoftFocus,
getSelectedThumbnailClass('studio-light-soft-focus')) }
onClick = { selectStudioSoftFocus }
onKeyPress = { selectStudioSoftFocusKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.studioLightSoftFocus')}
</div>
</Tooltip>
</div>
</div>
)}
</div>
)}
</>
@@ -502,6 +653,7 @@ function _mapStateToProps(state: IReduxState) {
const hasBrandingImages = Boolean(dynamicBrandingImages.length);
return {
_enableStudioLight: Boolean(state['features/base/config'].virtualBackground?.enableV2),
_images: (hasBrandingImages && dynamicBrandingImages) || IMAGES,
_showUploadButton: !state['features/base/config'].disableAddingBackgroundImages
};

View File

@@ -4,9 +4,72 @@
* @enum {string}
*/
export const VIRTUAL_BACKGROUND_TYPE = {
IMAGE: 'image',
BLUR: 'blur',
NONE: 'none'
IMAGE: 'image',
NONE: 'none',
STUDIO_LIGHT: 'studio-light'
};
/**
* Studio light preset identifiers.
*
* @enum {string}
*/
export enum StudioLightPreset {
NATURAL = 'natural',
SOFT_FOCUS = 'soft-focus',
SPOTLIGHT = 'spotlight'
}
/**
* Studio light shader parameter defaults (used when no preset is selected).
*/
export const STUDIO_LIGHT_DEFAULTS = {
bgDimming: 0.0,
brightness: 0.06,
contrast: 1.08,
glowIntensity: 0.03,
preset: StudioLightPreset.NATURAL,
saturation: 1.0,
skinSmoothing: 0.1,
toneB: 1.0,
toneG: 1.0,
toneR: 1.0
};
/**
* Maps each preset name to its shader parameters.
*
* Natural: clean, minimal enhancement — barely noticeable, background untouched.
* Spotlight: face pops against dimmed background, warm tone, pro studio lighting feel.
* Soft Focus: beauty/glamour filter — skin smoothing, glow bloom, lower contrast.
*/
export const STUDIO_LIGHT_PRESETS: Record<string, typeof STUDIO_LIGHT_DEFAULTS> = {
[StudioLightPreset.NATURAL]: { ...STUDIO_LIGHT_DEFAULTS },
[StudioLightPreset.SOFT_FOCUS]: {
bgDimming: 0.0,
brightness: 0.04,
contrast: 0.92,
glowIntensity: 0.18,
preset: StudioLightPreset.SOFT_FOCUS,
saturation: 1.08,
skinSmoothing: 0.55,
toneB: 1.0,
toneG: 1.0,
toneR: 1.0
},
[StudioLightPreset.SPOTLIGHT]: {
bgDimming: 0.4,
brightness: 0.10,
contrast: 1.10,
glowIntensity: 0.03,
preset: StudioLightPreset.SPOTLIGHT,
saturation: 1.02,
skinSmoothing: 0.12,
toneB: 0.99,
toneG: 1.0,
toneR: 1.01
}
};

View File

@@ -5,11 +5,25 @@ import { BACKGROUND_ENABLED, SET_VIRTUAL_BACKGROUND } from './actionTypes';
const STORE_NAME = 'features/virtual-background';
export interface IStudioLightOptions {
bgDimming?: number;
brightness?: number;
contrast?: number;
glowIntensity?: number;
preset?: string;
saturation?: number;
skinSmoothing?: number;
toneB?: number;
toneG?: number;
toneR?: number;
}
export interface IVirtualBackground {
backgroundEffectEnabled?: boolean;
backgroundType?: string;
blurValue?: number;
selectedThumbnail?: string;
studioLightOptions?: IStudioLightOptions;
virtualSource?: string;
}
@@ -25,7 +39,8 @@ export interface IVirtualBackground {
* specified action.
*/
ReducerRegistry.register<IVirtualBackground>(STORE_NAME, (state = {}, action): IVirtualBackground => {
const { virtualSource, backgroundEffectEnabled, blurValue, backgroundType, selectedThumbnail } = action;
const { virtualSource, backgroundEffectEnabled, blurValue, backgroundType, selectedThumbnail,
studioLightOptions } = action;
/**
* Sets up the persistence of the feature {@code virtual-background}.
@@ -39,7 +54,8 @@ ReducerRegistry.register<IVirtualBackground>(STORE_NAME, (state = {}, action): I
virtualSource,
blurValue,
backgroundType,
selectedThumbnail
selectedThumbnail,
studioLightOptions
};
}
case BACKGROUND_ENABLED: {

View File

@@ -9,7 +9,7 @@ import { IReduxState, IStore } from '../../../app/types';
import { getCurrentConference } from '../../../base/conference/functions';
import { IJitsiConference } from '../../../base/conference/reducer';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import { translate } from '../../../base/i18n/functions.native';
import { IconCloseLarge } from '../../../base/icons/svg';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
@@ -128,7 +128,7 @@ class Whiteboard extends PureComponent<IProps> {
safeAreaInsets = { [ 'bottom', 'left', 'right' ] }
style = { styles.backDrop }>
<WebView
domStorageEnabled = { false }
domStorageEnabled = { true }
incognito = { true }
javaScriptEnabled = { true }
nestedScrollEnabled = { true }

View File

@@ -1,14 +1,14 @@
import { ExcalidrawApp } from '@jitsi/excalidraw';
import clsx from 'clsx';
import i18next from 'i18next';
import React, { useCallback, useEffect, useRef } from 'react';
import React, { Suspense, useCallback, useEffect, useRef } from 'react';
import { WithTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useSelector, useStore } from 'react-redux';
// @ts-expect-error
import Filmstrip from '../../../../../modules/UI/videolayout/Filmstrip';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { getCurrentConference } from '../../../base/conference/functions';
import { translate } from '../../../base/i18n/functions.web';
import { getLocalParticipant } from '../../../base/participants/functions';
import { getVerticalViewMaxWidth } from '../../../filmstrip/functions.web';
import { getToolboxHeight } from '../../../toolbox/functions.web';
@@ -17,10 +17,20 @@ import { WHITEBOARD_UI_OPTIONS } from '../../constants';
import {
getCollabDetails,
getCollabServerUrl,
getStorageBackendUrl,
isWhiteboardOpen,
isWhiteboardVisible
} from '../../functions';
const LazyExcalidrawApp = React.lazy(async () => {
const [ { ExcalidrawApp } ] = await Promise.all([
import(/* webpackChunkName: "excalidraw" */ '@jitsi/excalidraw'),
import(/* webpackChunkName: "excalidraw" */ '@jitsi/excalidraw/index.css')
]);
return { default: ExcalidrawApp };
});
/**
* Space taken by meeting elements like the subject and the watermark.
*/
@@ -35,6 +45,12 @@ interface IDimensions {
width: string;
}
interface IMeetingDetails {
jwt: string;
roomJid: string;
sessionId: string;
}
/**
* The Whiteboard component.
*
@@ -42,7 +58,6 @@ interface IDimensions {
* @returns {JSX.Element} - The React component.
*/
const Whiteboard = (props: WithTranslation): JSX.Element => {
const excalidrawRef = useRef<any>(null);
const excalidrawAPIRef = useRef<any>(null);
const collabAPIRef = useRef<any>(null);
@@ -56,9 +71,23 @@ const Whiteboard = (props: WithTranslation): JSX.Element => {
const filmstripWidth: number = useSelector(getVerticalViewMaxWidth);
const collabDetails = useSelector(getCollabDetails);
const collabServerUrl = useSelector(getCollabServerUrl);
const storageBackendUrl = useSelector(getStorageBackendUrl);
const { defaultRemoteDisplayName } = useSelector((state: IReduxState) => state['features/base/config']);
const localParticipantName = useSelector(getLocalParticipant)?.name || defaultRemoteDisplayName || 'Fellow Jitster';
const jwt = useSelector((state: IReduxState) => state['features/base/jwt']).jwt || '';
const store = useStore();
const state = store.getState();
const conference = getCurrentConference(state);
const sessionId = conference?.getMeetingUniqueId();
const roomJid = conference?.room?.roomjid;
const meetingDetails: IMeetingDetails = {
sessionId: sessionId ?? '',
roomJid: roomJid ?? '',
jwt: jwt
};
useEffect(() => {
if (!collabAPIRef.current) {
return;
@@ -141,23 +170,23 @@ const Whiteboard = (props: WithTranslation): JSX.Element => {
{ props.t('whiteboard.accessibilityLabel.heading') }
</span>
}
<ExcalidrawApp
collabDetails = { collabDetails }
collabServerUrl = { collabServerUrl }
excalidraw = {{
isCollaborating: true,
langCode: i18next.language,
// @ts-ignore
ref: excalidrawRef,
theme: 'light',
UIOptions: WHITEBOARD_UI_OPTIONS
}}
getCollabAPI = { getCollabAPI }
getExcalidrawAPI = { getExcalidrawAPI } />
<Suspense fallback = { null }>
<LazyExcalidrawApp
collabDetails = { collabDetails }
collabServerUrl = { collabServerUrl }
excalidraw = {{
isCollaborating: true,
langCode: i18next.language,
theme: 'light',
UIOptions: WHITEBOARD_UI_OPTIONS
}}
getCollabAPI = { getCollabAPI }
getExcalidrawAPI = { getExcalidrawAPI }
meetingDetails = { meetingDetails }
storageBackendUrl = { storageBackendUrl } />
</Suspense>
</div>
)
}
)}
</div>
);
};

View File

@@ -1,4 +1,3 @@
import { generateCollaborationLinkData } from '@jitsi/excalidraw';
import React, { ComponentType } from 'react';
import BaseApp from '../../../base/app/components/BaseApp';
@@ -33,6 +32,9 @@ export default class WhiteboardApp extends BaseApp<any> {
if (!roomId && !roomKey) {
try {
const { generateCollaborationLinkData } = await import(
/* webpackChunkName: "excalidraw" */ '@jitsi/excalidraw'
);
const collabDetails = await generateCollaborationLinkData();
roomId = collabDetails.roomId;

View File

@@ -1,8 +1,19 @@
import { ExcalidrawApp } from '@jitsi/excalidraw';
import i18next from 'i18next';
import React, { useCallback, useRef } from 'react';
import React, { Suspense, useCallback, useRef } from 'react';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { WHITEBOARD_UI_OPTIONS } from '../../constants';
import { getStorageBackendUrl } from '../../functions';
const LazyExcalidrawApp = React.lazy(async () => {
const [ { ExcalidrawApp } ] = await Promise.all([
import(/* webpackChunkName: "excalidraw" */ '@jitsi/excalidraw'),
import(/* webpackChunkName: "excalidraw" */ '@jitsi/excalidraw/index.css')
]);
return { default: ExcalidrawApp };
});
/**
* Whiteboard wrapper for mobile.
@@ -23,9 +34,10 @@ const WhiteboardWrapper = ({
collabServerUrl: string;
localParticipantName: string;
}) => {
const excalidrawRef = useRef<any>(null);
const excalidrawAPIRef = useRef<any>(null);
const collabAPIRef = useRef<any>(null);
const storageBackendUrl = useSelector(getStorageBackendUrl);
const jwt = useSelector((state: IReduxState) => state['features/base/jwt']).jwt || '';
const getExcalidrawAPI = useCallback(excalidrawAPI => {
if (excalidrawAPIRef.current) {
@@ -45,24 +57,22 @@ const WhiteboardWrapper = ({
return (
<div className = { className }>
<div className = 'excalidraw-wrapper'>
<ExcalidrawApp
collabDetails = { collabDetails }
collabServerUrl = { collabServerUrl }
detectScroll = { true }
excalidraw = {{
isCollaborating: true,
langCode: i18next.language,
// @ts-ignore
ref: excalidrawRef,
theme: 'light',
UIOptions: WHITEBOARD_UI_OPTIONS
}}
getCollabAPI = { getCollabAPI }
getExcalidrawAPI = { getExcalidrawAPI } />
<Suspense fallback = { null }>
<LazyExcalidrawApp
collabDetails = { collabDetails }
collabServerUrl = { collabServerUrl }
excalidraw = {{
isCollaborating: true,
langCode: i18next.language,
theme: 'light',
UIOptions: WHITEBOARD_UI_OPTIONS
}}
getCollabAPI = { getCollabAPI }
getExcalidrawAPI = { getExcalidrawAPI }
jwt = { jwt }
storageBackendUrl = { storageBackendUrl } />
</Suspense>
</div>
</div>
);
};

View File

@@ -14,7 +14,7 @@ export const WHITEBOARD_ID = 'whiteboard';
export const WHITEBOARD_UI_OPTIONS = {
canvasActions: {
allowedShapes: [
'arrow', 'diamond', 'ellipse', 'freedraw', 'line', 'rectangle', 'selection', 'text'
'arrow', 'diamond', 'ellipse', 'freedraw', 'line', 'rectangle', 'selection', 'text', 'eraser'
],
allowedShortcuts: [
'cut', 'deleteSelectedElements', 'redo', 'selectAll', 'undo'
@@ -33,15 +33,18 @@ export const WHITEBOARD_UI_OPTIONS = {
hideFontFamily: true,
hideHelpDialog: true,
hideIOActions: true,
hideLaserOnCollaboration: true,
hideLayers: true,
hideLibraries: true,
hideLockButton: true,
hideOpacityInput: true,
hideEmbedableTools: true,
hideSharpness: true,
hideStrokeStyle: true,
hideTextAlign: true,
hideThemeControls: true,
hideUserList: true,
hideWelcomeScreen: true,
saveAsImageOptions: {
defaultBackgroundValue: true,
disableScale: true,

View File

@@ -114,6 +114,16 @@ export const generateCollabServerUrl = (state: IReduxState): string | undefined
export const getCollabServerUrl = (state: IReduxState): string | undefined =>
getWhiteboardState(state).collabServerUrl;
/**
* Returns the storage backend URL for saving whiteboard scenes and images.
*
* @param {IReduxState} state - The state from the Redux store.
* @returns {string}
*/
export const getStorageBackendUrl = (state: IReduxState): string | undefined =>
getWhiteboardConfig(state).storageBackendUrl;
/**
* Whether the whiteboard is visible on stage.
*

Some files were not shown because too many files have changed in this diff Show More