mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-07-27 10:17:47 +00:00
Compare commits
54 Commits
dependabot
...
virtual-ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd935af7ef | ||
|
|
1679c4f56a | ||
|
|
ed707190e3 | ||
|
|
cc220c6058 | ||
|
|
deb687f3ef | ||
|
|
b64c882b0c | ||
|
|
5049a5664c | ||
|
|
25e9d36a64 | ||
|
|
d93dc6870d | ||
|
|
88c3b9c974 | ||
|
|
279638111a | ||
|
|
e5fb124baf | ||
|
|
c09719dabf | ||
|
|
41ba851275 | ||
|
|
db49f6fd45 | ||
|
|
144bc6529d | ||
|
|
ac84afae7c | ||
|
|
3f316dde56 | ||
|
|
c08a1d4b11 | ||
|
|
f132ed6c5c | ||
|
|
fc4186b052 | ||
|
|
80ad2c9d16 | ||
|
|
4f06c43eb7 | ||
|
|
5a11cd8801 | ||
|
|
d11a9daf41 | ||
|
|
6f4412db6e | ||
|
|
8ff122720d | ||
|
|
57b820f77f | ||
|
|
9b2b909b72 | ||
|
|
9a3917942f | ||
|
|
2d6be4ea27 | ||
|
|
cfd790731d | ||
|
|
04dc27b9e8 | ||
|
|
6534e6f277 | ||
|
|
5e58c9b133 | ||
|
|
2c43785f54 | ||
|
|
e672aaa35a | ||
|
|
95c420cab9 | ||
|
|
2d534bf2f7 | ||
|
|
d93b1c2195 | ||
|
|
5669d5f63e | ||
|
|
aac7606b32 | ||
|
|
716ceb0930 | ||
|
|
7574649887 | ||
|
|
e231d7815e | ||
|
|
d51c9eed6f | ||
|
|
2cc6953b60 | ||
|
|
751d1fae36 | ||
|
|
e9daf4395e | ||
|
|
2245e4e747 | ||
|
|
43132a7eba | ||
|
|
4f3dc195cf | ||
|
|
a80a732ec4 | ||
|
|
04757f103e |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
31
Makefile
31
Makefile
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
54
config.js
54
config.js
@@ -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
5
custom.d.ts
vendored
@@ -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
10
globals.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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 ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
6
modules/API/external/external_api.js
vendored
6
modules/API/external/external_api.js
vendored
@@ -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
4502
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -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",
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
*.tgz
|
||||
tsconfig.json
|
||||
.npmrc
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -242,6 +242,7 @@ export default [
|
||||
'useHostPageLocalStorage',
|
||||
'useTurnUdp',
|
||||
'videoQuality',
|
||||
'virtualBackground',
|
||||
'visitors.enableMediaOnPromote',
|
||||
'visitors.hideVisitorCountForVisitors',
|
||||
'visitors.showJoinMeetingDialog',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -74,6 +74,7 @@ const {
|
||||
IconPlus,
|
||||
IconRaiseHand,
|
||||
IconRecord,
|
||||
IconTranscription,
|
||||
IconRecordAccount,
|
||||
IconRecordContact,
|
||||
IconRecordLead,
|
||||
@@ -194,6 +195,7 @@ export {
|
||||
IconPlus,
|
||||
IconRaiseHand,
|
||||
IconRecord,
|
||||
IconTranscription,
|
||||
IconRecordAccount,
|
||||
IconRecordContact,
|
||||
IconRecordLead,
|
||||
|
||||
4
react/features/base/icons/svg/transcription.svg
Normal file
4
react/features/base/icons/svg/transcription.svg
Normal 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 |
@@ -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]}`;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 !== '' && (
|
||||
|
||||
@@ -326,6 +326,7 @@ export interface IPalette {
|
||||
recordingNotificationAction: string;
|
||||
recordingNotificationText: string;
|
||||
recordingText: string;
|
||||
transcriptionIndicator: string;
|
||||
securityDialogBackground: string;
|
||||
securityDialogBorder: string;
|
||||
securityDialogSecondaryText: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ export function moveToRoom(roomId?: string) {
|
||||
}
|
||||
|
||||
APP.conference.joinRoom(_roomId, {
|
||||
isBreakoutRoom: true,
|
||||
startWithAudioMuted: isAudioMuted,
|
||||
startWithVideoMuted: isVideoMuted
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* displayName: string
|
||||
* hasRead: boolean,
|
||||
* id: string,
|
||||
* messageType: string,
|
||||
* messageType: ChatMessageType,
|
||||
* message: string,
|
||||
* timestamp: string,
|
||||
* }
|
||||
|
||||
@@ -42,7 +42,7 @@ import { ChatTabs } from './constants';
|
||||
* displayName: string,
|
||||
* hasRead: boolean,
|
||||
* message: string,
|
||||
* messageType: string,
|
||||
* messageType: ChatMessageType,
|
||||
* timestamp: string,
|
||||
* isReaction: boolean
|
||||
* }}
|
||||
|
||||
@@ -151,7 +151,7 @@ const useStyles = makeStyles<{
|
||||
|
||||
'*': {
|
||||
userSelect: 'text',
|
||||
'-webkit-user-select': 'text'
|
||||
WebkitUserSelect: 'text'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
react/features/custom-panel/functions.custom.ts
Normal file
5
react/features/custom-panel/functions.custom.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Custom panel functions placeholder.
|
||||
* Override to add custom panel functionality.
|
||||
*/
|
||||
export {}; // we need this just to make TS happy!
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
4
react/features/custom-panel/middleware.custom.web.ts
Normal file
4
react/features/custom-panel/middleware.custom.web.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Custom panel middleware placeholder.
|
||||
* Override to add custom panel functionality.
|
||||
*/
|
||||
@@ -1,4 +1,2 @@
|
||||
/**
|
||||
* Custom panel middleware placeholder.
|
||||
* Override to add custom panel functionality.
|
||||
*/
|
||||
import './middleware.custom.web';
|
||||
import './subscriber.web';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
74
react/features/custom-panel/subscriber.web.ts
Normal file
74
react/features/custom-panel/subscriber.web.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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')!);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ export const getKeyboardKey = (e: KeyboardEvent): string => {
|
||||
|
||||
const replacedKey = code.replace('Key', '');
|
||||
|
||||
if (ctrlKey && altKey) {
|
||||
return `-:${replacedKey}`;
|
||||
}
|
||||
|
||||
if (altKey) {
|
||||
return `:${replacedKey}`;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
4
react/features/overlay/actionTypes.ts
Normal file
4
react/features/overlay/actionTypes.ts
Normal 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';
|
||||
14
react/features/overlay/actions.any.ts
Normal file
14
react/features/overlay/actions.any.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
120
react/features/recording/components/web/TranscribingLabel.tsx
Normal file
120
react/features/recording/components/web/TranscribingLabel.tsx
Normal 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);
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 = '';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
BIN
react/features/stream-effects/virtual-background/vendor/models/selfie_segmenter.tflite
vendored
Normal file
BIN
react/features/stream-effects/virtual-background/vendor/models/selfie_segmenter.tflite
vendored
Normal file
Binary file not shown.
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user