Compare commits

...

66 Commits

Author SHA1 Message Date
dependabot[bot]
0d32907178 chore(ci): bump actions/setup-node from 6.1.0 to 6.2.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](395ad32622...6044e13b5d)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 17:55:45 +00:00
Calinteodor
481b9a6e58 feat(tracks): add/remove "ended" listener when screen sharing
* Listen to the MediaStreamTrack "ended" event so local screen-share state is toggled off when capture is terminated externally.
2026-01-19 16:07:59 +02:00
damencho
fb3bc3c367 feat(dynamic-branding): Adds more options for overriding translations.
In branding you can add now
"labels-translation-languages": {
        "en": "/static/translation-overwritten-en.json"
    }

This allows overwriting strings from the translation-languages namespace, till now it was possible only for the main one.
2026-01-16 07:47:54 -06:00
zobadaniel
9fa5489154 add lower sorbian to the list of codes (#16844) 2026-01-16 07:39:02 -06:00
Anton
9499bf29ed fix(premeeting): device status indicator size when label is long (#16559) 2026-01-16 09:14:32 +01:00
José Luís Andrade
f605b5c487 lang: Update Portuguese translations in main-pt.json (#16790) 2026-01-15 22:04:30 -06:00
Nicolas
88fba5acab lang: Updates to Danish translation (#16781) 2026-01-15 22:01:23 -06:00
rassul
7bc79bc144 feat(lang): added kk translations 2026-01-15 21:50:00 -06:00
bgrozev
3e469019b5 test: Add a test for conference-request over XMPP. (#16838)
* test: Add a test for conference-request over XMPP.
2026-01-15 10:41:03 -06:00
bgrozev
d324935501 doc: Add a comment about JWT verification. (#16837) 2026-01-14 11:39:14 -06:00
Tobias
cd11cf6f65 ci: bump and pin Actions (#16553) 2026-01-14 10:24:41 +01:00
Matteo
5db3d529f4 lang: Update Italian translation 2026-01-13 18:40:46 -06:00
damencho
c7d2c9c204 fix(token): Keeps checks for allowlist only. 2026-01-12 15:51:55 -06:00
Harsh_w/n
9832c7a226 fix(recording): use cloned tracks for local recording; reset flags on stop (#16535)
Fixes: https://github.com/jitsi/jitsi-meet/issues/16491
2026-01-12 10:45:26 +01:00
mishraditi
12ee929499 docs: fix Contributing Guidelines link 2026-01-09 17:07:01 +01:00
vishal2005025
b9ed42613b fix(android): support String[] config overrides in mergeProps 2026-01-09 13:14:33 +02:00
Calinteodor
0d572b3bfb feat(participants-pane/native): fix participants sort (#13997)
* Make participants sort based on what web does.
2026-01-08 15:30:37 +02:00
damencho
609eab5f83 fix(chat): Clears private message notice if sender leaves.
If you are in a private conversation with someone and that participant leaves, the selection of messages switches to Everyone but the private message notice stays.
2026-01-07 12:33:38 -06:00
damencho
7bd0f479b9 fix(chat): Fixes sending messages after private message sender leaves. 2026-01-07 12:33:38 -06:00
Christoph Settgast
a634b6b2bc lang(de): Update chat permissions label as in #16807 2026-01-07 12:33:26 -06:00
Дамян Минков
0e53bd87ce fix(groupchat-polls-permissions): Adds UI setting to show the option. (#16807)
* fix(groupchat-polls-permissions): Adds UI setting to show the option.

Make sure we do not show the setting when everyone is a moderator in the room.

* squash: Change text and default value.

* squash: Defaults to false.

* squash: Set it in metadata.

* squash: Whitelist the config.
2026-01-05 16:27:32 -06:00
damencho
9e89c33796 fix(prosody): Fixes log message. 2026-01-05 13:18:36 -06:00
damencho
eaffd8b8f7 fix(breakout-rooms): Fix polls in main when breakout is enabled. 2026-01-05 13:18:36 -06:00
bgrozev
b8444d56ff fix: Fix nesting in the allure report (after allure 3 update). (#16816) 2026-01-05 13:15:47 -06:00
Christoph Settgast
42b2dd41c1 ci: fix android SDK build by removing unused preinstalled tools from github runner image (#16811)
* ci: try to fix android build by removing preinstalled stuff

The android SDK build anyway uses a docker image with react native and an android SDK inside, so remove android and haskell from the host runner

* More removals of preinstalled unused SDKs

dotnet is another 4GB, swift another 3GB
2025-12-30 14:58:22 -06:00
Christoph Settgast
229d1823fb lang: update German translation
Signed-off-by: Christoph Settgast <csett86_git@quicksands.de>
2025-12-30 12:20:45 -06:00
damencho
cd6e905b95 feat(test): Updates wdio to 9.22.0. 2025-12-29 13:40:50 -06:00
damencho
ddaf7a3180 fix(transcriptions): Drops not needed parameter.
Fixes an issue with iFrameAPI where toggleSubtitles will not do anything in case of async transcriptions turned on.
2025-12-29 12:24:47 -06:00
damencho
47aa51a58c fix(polls): Drops not needed check. 2025-12-29 12:24:36 -06:00
damencho
66f7b9de53 fix(polls): Updates polls validation. 2025-12-29 12:24:36 -06:00
emrah
bab87be9c9 fix(turnserver): no dtls 2025-12-23 14:02:53 +01:00
Jaya Allamsetty
6549d47233 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2116.0.0+40ad2744...v2118.0.0+67fd2c84
2025-12-19 15:15:16 -05:00
damencho
2063e66b8e fix(transcription): Fixes a UI bug where you cannot start transcription 2nd time. 2025-12-17 11:55:19 -06:00
Mihaela Dumitru
4dd241712d feat(external-api) expose mute remote command and participant muted event (#16768) 2025-12-17 14:14:30 +02:00
Calinteodor
a574d5ec79 feat(conference): apply reduce ui for web (#16763)
* Change stage view and use newly reducedUImainToolbarButtons config to show different custom buttons as main toolbar buttons for when web is in reduced UI.
2025-12-17 12:17:06 +02:00
Vishal Malyan
4b2b85bd12 fix(avatar) fix memory leak in preloadImage 2025-12-16 22:52:24 +01:00
Jaya Allamsetty
77ab1ea8ed fix(large-video) Fix auto-pinning of SS in large meetings. (#16773)
Fix(large-video) Fox auto pinning of screenshare in large meetings
2025-12-16 15:09:58 -05:00
Дамян Минков
05e47ade7c feat(lobby): Handle disabling lobby. (#16770)
* feat(lobby): Handle disabling lobby.

* squash: rename field.
2025-12-16 08:21:39 -06:00
damencho
6c78ec9099 fix(recording): Fixes inviting jigasi when backend recording is enabled. 2025-12-15 16:24:55 -06:00
Hristo Terezov
4c5afc0b5e chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2115.0.0+cc2f34c2...v2116.0.0+40ad2744
2025-12-08 18:52:57 -06:00
damencho
2e31ab9dca fix: Using recording dialog on asyncTranscription.
This partially reverts commit 02787b1 to avoid looping between request subtitles and start recording which results not closing the start recording dialog.
2025-12-08 14:00:51 -06:00
damencho
02787b1394 feat: Use recording dialog on asyncTranscription. 2025-12-05 16:10:24 -06:00
damencho
2476a06237 feat: Drops obsolete inviteJigasiOnBackendTranscribing. 2025-12-05 16:10:24 -06:00
Hristo Terezov
2d8909911e fix(pip): Fix PiP not working when enabled via overwriteConfig
Addresses multiple issues when enabling/disabling PiP dynamically:

1. External API: Replace dead config-overwrite event handler with
   interception in executeCommand. This properly manages the intersection
   observer and PiP state when pip config changes via overwriteConfig.

2. PiPVideoElement: Fix ref access pattern - access videoRef.current
   inside useEffects instead of capturing at render time. The captured
   value was null on first render, causing blur/focus listeners to never
   be set up when component mounted into a stable conference.

3. useCanvasAvatar: Return streamRef object instead of refs.current.stream
   so consumers can access .current inside their effects. The stream is
   created in an effect and wasn't available at render time.

4. Add on-mount focus check with loadedmetadata wait to handle PiP enable
   while app is in background, ensuring video source is ready before
   attempting to enter PiP mode.
2025-12-05 06:47:03 -06:00
Hristo Terezov
d06b847319 feat(pip): Add Picture-in-Picture support for Electron
Implements Picture-in-Picture functionality for the Electron wrapper to maintain video engagement when users are not actively focused on the conference window. This feature addresses the need to keep users visually connected to the conference even when multitasking.

Key features:
- Automatic PiP mode activation and deactivation based on user interaction
- Displays large video participant's stream or renders their avatar on canvas when video unavailable
- Provides audio/video mute controls via MediaSession API directly in PiP window
- Adds API events (_pip-requested) for Electron wrapper integration

Implementation includes new pip feature module with Redux architecture, canvas-based avatar rendering with custom backgrounds support, and integration with existing mute/unmute logic. Depends on jitsi-meet-electron-sdk#479 for proper user gesture handling in Electron.
2025-12-04 16:04:10 -06:00
Hristo Terezov
b517f614b3 fix(RN): Remove web files from build.
filmstrip/actions.web was imported in TileView native component.
filmstrip/actions.web was imported in config middleware.any.
2025-12-04 16:04:10 -06:00
Bastien Le Gall
10f77f1fbc fix(visitors): Fix s2sout host-unknown errors by skipping domain mapping 2025-12-04 14:26:56 -06:00
Vishal Malyan
77b89ece4a feat(api): add toolbarVisibilityChanged event to the IFrame API (#16659)
* Change toolbar background color from IFrame API #16468 fixed

* fix(toolbar #16468): implement toolbar background color via configOverwrite for web and mobile

* keep toolbarConfig defaults commented in config.js

* add trailing comma to commented toolbarConfig.backgroundColor

* fix: resolve linting errors

* feat(api): add toolbarVisibilityChanged event to IFrame API

* fix lint
2025-12-04 14:26:45 -06:00
Florian
129264c3c9 lang: Remove BETA label from noise suppression button (#16730)
Remove BETA label from noise suppression button as it was updated to a new stable RNNoise version
2025-12-03 08:19:33 -06:00
bgrozev
96c5a9abd1 fix: Fix reading transcription results from JVB. (#16725)
* fix: Fix reading transcription results from JVB.
2025-12-02 09:47:22 -06:00
Calinteodor
93ef2337ae dep(react-native): replace future deprecated SafeAreaView component (#16726)
* Replaced with react-native-safe-area-context component and fixed types around edges prop.
2025-12-02 16:34:57 +02:00
Дамян Минков
854a077684 fix(lobby): Send virtual jid for main room destroy. (#16724)
* fix(lobby): Send virtual jid for main room destroy.

* squash: Update and when manually disable lobby.
2025-12-01 12:57:26 -06:00
damencho
f903a7ae6e fix(prosody): Skips printing errors for reactions from visitors. 2025-12-01 09:26:29 -07:00
Saúl Ibarra Corretgé
9013881f76 chore(ci) clean Android build to save space
Add git clean command to CI workflow for Android.
2025-11-27 12:45:54 +01:00
dependabot[bot]
b6e7e0a19e chore(ci): bump actions/setup-node from 4 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-27 12:21:16 +01:00
dependabot[bot]
ae42e42534 chore(ci): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-27 11:17:58 +01:00
Saúl Ibarra Corretgé
21e2504cf9 chore(ci) add Dependabot configuration for GitHub Actions 2025-11-27 09:55:51 +01:00
dependabot[bot]
7a9ba79783 chore(deps): bump node-forge from 1.3.1 to 1.3.2
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.1 to 1.3.2.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-27 08:51:30 +01:00
Damien Fetis
1f5a3b5b0f fix(recording): allow samesite iframe embeds to work with local recording
* fix(recording): allow samesite iframe embeds to work with local recording

Skip capture handle validation when inside an iframe to ensure local
recording works. This only applies if the iframe is served from the
same domain.

* fix(recording): add missing line breaks for better readability in LocalRecordingManager
2025-11-26 07:35:23 -07:00
bgrozev
fe2aff4f3c chore(deps) lib-jitsi-meet@latest (#16706)
https://github.com/jitsi/lib-jitsi-meet/compare/v2114.0.0+0e62818c...v2115.0.0+cc2f34c2
2025-11-25 11:57:53 -06:00
bgrozev
d847f6f96b feat: Accept transcription messages from non-participant entities. (#16631) 2025-11-25 11:06:16 -06:00
damencho
45ce467dcd feat(polls): Fixes support for breakout rooms.
Fixes #16693.
2025-11-25 09:30:10 -06:00
bgrozev
2b81fa6bd3 config(webpack): Listen on localhost by default. (#16703) 2025-11-25 08:42:19 -06:00
damencho
6f6100ceb2 chore(deps) lib-jitsi-meet@latest
https://github.com/jitsi/lib-jitsi-meet/compare/v2113.0.0+ffcffaa7...v2114.0.0+0e62818c
2025-11-21 05:56:46 -06:00
Calin-Teodor
62cd1c29d7 align react native docker version with package json version 2025-11-21 13:45:53 +02:00
Дамян Минков
64869e8970 fix(deb): Adds Include on upgrade prosody. (#16687)
* fix(deb): Adds Include on upgrade prosody.

* squash: Restart if config has changed.
2025-11-20 16:13:29 -06:00
124 changed files with 7455 additions and 2622 deletions

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
version: 2
updates:
# Monitor GitHub Actions versions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "chore(ci)"

View File

@@ -7,7 +7,7 @@ jobs:
name: Luacheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Install luarocks
run: sudo apt-get --install-recommends -y install luarocks

View File

@@ -7,8 +7,8 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -42,8 +42,8 @@ jobs:
matrix:
os: [macos-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -59,8 +59,8 @@ jobs:
name: Build mobile bundle (Android)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -74,8 +74,8 @@ jobs:
name: Build mobile bundle (iOS)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -103,10 +103,21 @@ jobs:
android-sdk-build:
name: Build mobile SDK (Android)
runs-on: ubuntu-latest
container: reactnativecommunity/react-native-android:v18.0
container:
image: reactnativecommunity/react-native-android:v15.0
volumes:
- /usr:/host/usr
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Make space in image by removing preinstalled, but unused SDKs
run: |
df -h /
rm -rf /host/usr/local/lib/android
rm -rf /host/usr/local/.ghcup
rm -rf /host/usr/share/dotnet
rm -rf /host/usr/share/swift
df -h /
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -119,12 +130,15 @@ jobs:
cd android
./gradlew :sdk:clean
./gradlew :sdk:assembleRelease
- run: |
git config --global --add safe.directory /__w/jitsi-meet/jitsi-meet
git clean -dfx
ios-sdk-build:
name: Build mobile SDK (iOS)
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -146,7 +160,7 @@ jobs:
-workspace ios/jitsi-meet.xcworkspace \
-scheme JitsiMeetSDK
- name: setup-cocoapods
uses: ruby/setup-ruby@v1
uses: ruby/setup-ruby@4fc31e1c823882afd7ef55985266a526c589de90 #v1.282.0
with:
ruby-version: '3.4'
bundler-cache: true
@@ -173,8 +187,8 @@ jobs:
name: Test Debian packages build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 #v6.2.0
with:
node-version-file: '.nvmrc'
cache: 'npm'

View File

@@ -7,7 +7,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v8
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 #v10.1.0
with:
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'

View File

@@ -264,4 +264,4 @@ react/features/sample/
- [Jitsi Handbook](https://jitsi.github.io/handbook/) - Comprehensive documentation
- [Community Forum](https://community.jitsi.org/) - Ask questions and get support
- [Architecture Guide](https://jitsi.github.io/handbook/docs/architecture) - System overview
- [Contributing Guidelines](https://jitsi.github.io/handbook/docs/dev-guide/contributing) - Detailed contribution process
- [Contributing Guidelines](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-contributing/) - Detailed contribution process

View File

@@ -30,6 +30,9 @@ import com.facebook.react.ReactRootView;
import org.jitsi.meet.sdk.log.JitsiMeetLogger;
import java.util.ArrayList;
import java.util.Arrays;
public class JitsiMeetView extends FrameLayout {
@@ -84,6 +87,10 @@ public class JitsiMeetView extends FrameLayout {
result.putInt(key, (int)bValue);
} else if (valueType.contentEquals("Bundle")) {
result.putBundle(key, mergeProps((Bundle)aValue, (Bundle)bValue));
} else if (valueType.contentEquals("String[]")) {
// Convert String[] to ArrayList<String> for React Native bridge compatibility
String[] stringArray = (String[]) bValue;
result.putStringArrayList(key, new ArrayList<>(Arrays.asList(stringArray)));
} else {
throw new RuntimeException("Unsupported type: " + valueType);
}

View File

@@ -151,6 +151,9 @@ var config = {
// Disables self-view settings in UI
// disableSelfViewSettings: false,
// Shows/hides the moderator setting for chat permissions.
// showChatPermissionsModeratorSetting: false,
// screenshotCapture : {
// Enables the screensharing capture feature.
// enabled: false,
@@ -520,9 +523,6 @@ var config = {
// // Note: Starting transcriptions from the recording dialog will still work.
// disableClosedCaptions: false,
// // Whether to invite jigasi when backend transcriptions are enabled (asyncTranscription is true in metadata).
// // By default, we invite it.
// inviteJigasiOnBackendTranscribing: true,
// },
// Misc
@@ -927,6 +927,11 @@ var config = {
// [ 'microphone', 'camera' ]
// ],
// Overrides the buttons displayed in the main toolbar for reduced UI.
// When there isn't an override for a certain configuration the default jitsi-meet configuration will be used.
// The order of the buttons in the array is preserved.
// reducedUImainToolbarButtons: [ 'microphone', 'camera' ],
// Toolbar buttons which have their click/tap event exposed through the API on
// `toolbarButtonClicked`. Passing a string for the button key will
// prevent execution of the click/tap routine; passing an object with `key` and

5
custom.d.ts vendored
View File

@@ -2,3 +2,8 @@ declare module '*.svg' {
const content: any;
export default content;
}
declare module '*.svg?raw' {
const content: string;
export default content;
}

View File

@@ -124,10 +124,17 @@ case "$1" in
ln -s $PROSODY_HOST_CONFIG /etc/prosody/conf.d/$JVB_HOSTNAME.cfg.lua
fi
PROSODY_CREATE_JICOFO_USER="true"
fi
if ! grep -q "VirtualHost \"$JVB_HOSTNAME\"" $PROSODY_CONFIG_OLD; then
# on some distributions main prosody config doesn't include configs
# from conf.d folder enable it as this where we put our config by default
# also when upgrading to new prosody version from prosody repo we need to add it again
if ! grep -q "Include \"conf\.d\/\*\.cfg.lua\"" $PROSODY_CONFIG_OLD; then
echo -e "\nInclude \"conf.d/*.cfg.lua\"" >> $PROSODY_CONFIG_OLD
# trigger a restart
PROSODY_CONFIG_PRESENT="false"
fi
fi

View File

@@ -10,6 +10,7 @@ no-cli
no-loopback-peers
no-tcp-relay
no-tcp
no-dtls
listening-port=3478
tls-listening-port=5349
no-tlsv1

View File

@@ -31,6 +31,7 @@
"it": "Italiano",
"ja": "日本語",
"kab": "Taqbaylit",
"kk": "Қазақша",
"ko": "한국어",
"lt": "Lietuvių",
"lv": "Latviešu",

File diff suppressed because it is too large Load Diff

View File

@@ -109,9 +109,15 @@
}
},
"chat": {
"disabled": "Chat-Nachrichten sind deaktiviert.",
"enter": "Chat-Raum betreten",
"error": "Fehler: Ihre Nachricht wurde nicht versendet. Grund: {{error}}",
"everyone": "Alle",
"fieldPlaceHolder": "Geben Sie Ihre Nachricht hier ein",
"fileAccessibleTitle": "{{user}} hat eine Datei hochgeladen",
"fileAccessibleTitleMe": "Ich habe eine Datei hochgeladen",
"fileDeleted": "Eine Datei wurde gelöscht",
"guestsChatIndicator": "(Gast)",
"lobbyChatMessageTo": "Lobby-Nachricht an {{recipient}}",
"message": "Nachricht",
"messageAccessibleTitle": "{{user}} sagt:",
@@ -120,11 +126,20 @@
"messagebox": "Nachricht eingeben",
"newMessages": "Neue Nachrichten",
"nickname": {
"featureChat": "Chat",
"featureClosedCaptions": "Untertitel",
"featureFileSharing": "Dateien",
"featurePolls": "Umfragen",
"popover": "Wähle einen Alias",
"title": "Geben Sie einen Alias zum Chatten ein",
"titleWith1Features": "Geben Sie einen Alias ein, um {{feature1}} zu nutzen",
"titleWith2Features": "Geben Sie einen Alias ein, um {{feature1}} und {{feature2}} zu nutzen",
"titleWith3Features": "Geben Sie einen Alias ein, um {{feature1}}, {{feature2}} und {{feature3}} zu nutzen",
"titleWith4Features": "Geben Sie einen Alias ein, um {{feature1}}, {{feature2}}, {{feature3}} und {{feature4}} zu nutzen",
"titleWithCC": "Geben Sie einen Alias zum Chatten und für Untertitel ein",
"titleWithPolls": "Geben Sie einen Alias zum Chatten und für Umfragen ein",
"titleWithPollsAndCC": "Geben Sie einen Alias zum Chatten, für Umfragen und Untertitel ein"
"titleWithPollsAndCC": "Geben Sie einen Alias zum Chatten, für Umfragen und Untertitel ein",
"titleWithPollsAndCCAndFileSharing": "Geben Sie einen Alias zum Chatten, für Umfragen, Untertitel und Dateien ein"
},
"noMessagesMessage": "Es gibt noch keine Nachricht in dieser Konferenz. Starten Sie hier eine Unterhaltung!",
"privateNotice": "Private Nachricht an {{recipient}}",
@@ -132,14 +147,16 @@
"smileysPanel": "Emoji-Auswahl",
"systemDisplayName": "System",
"tabs": {
"chat": "Chatten",
"chat": "Chat",
"closedCaptions": "Untertitel",
"fileSharing": "Dateien",
"polls": "Umfragen"
},
"title": "Chatten",
"titleWithCC": "Chatten und Untertitel",
"titleWithPolls": "Chatten und Umfragen",
"titleWithPollsAndCC": "Chatten, Umfragen und Untertitel",
"title": "Chat",
"titleWithCC": "Untertitel",
"titleWithFeatures": "Chat und",
"titleWithFileSharing": "Dateien",
"titleWithPolls": "Umfragen",
"you": "Sie"
},
"chromeExtensionBanner": {
@@ -274,7 +291,6 @@
"Submit": "OK",
"Understand": "Verstanden, Stummschaltung beibehalten",
"UnderstandAndUnmute": "Verstanden, bitte Stummschaltung aufheben",
"WaitForHostMsg": "Die Konferenz wurde noch nicht gestartet. Falls Sie die Konferenz leiten, authentifizieren Sie sich bitte. Warten Sie andernfalls, bis die Konferenz gestartet wird.",
"WaitForHostNoAuthMsg": "Die Konferenz wurde noch nicht gestartet. Bitte warten Sie, bis die Konferenz gestartet wird.",
"WaitingForHostButton": "Auf Moderation warten",
"WaitingForHostTitle": "Warten auf den Beginn der Konferenz …",
@@ -296,6 +312,12 @@
"alreadySharedVideoTitle": "Nur ein geteiltes Video gleichzeitig",
"applicationWindow": "Anwendungsfenster",
"authenticationRequired": "Authentifizierung benötigt",
"cameraCaptureDialog": {
"description": "Ein Bild mit Ihrer Kamera aufnehmen und senden",
"ok": "Kamera starten",
"reject": "Jetzt nicht",
"title": "Ein Bild aufnehmen"
},
"cameraConstraintFailedError": "Ihre Kamera erfüllt die notwendigen Anforderungen nicht.",
"cameraNotFoundError": "Kamera nicht gefunden.",
"cameraNotSendingData": "Die Kamera ist nicht verfügbar. Bitte prüfen, ob eine andere Applikation die Kamera verwendet, eine andere Kamera vom Einstellungs-Menü auswählen oder die Applikation neu laden.",
@@ -371,22 +393,34 @@
"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",
"moderationVideoLabel": "Erlaube Anwesenden ihre Kamera einzuschalten",
"muteEveryoneDialog": "Wollen Sie wirklich alle stummschalten? Sie können deren Stummschaltung nicht mehr beenden, aber sie können ihre Stummschaltung jederzeit selbst beenden.",
"muteEveryoneDialogModerationOn": "Die Anwesenden können eine Anfrage zum Sprechen jederzeit senden.",
"muteEveryoneElseDialog": "Einmal stummgeschaltet, können Sie deren Stummschaltung nicht mehr beenden, aber sie können ihre Stummschaltung jederzeit selbst beenden.",
"muteEveryoneElseTitle": "Alle außer {{whom}} stummschalten?",
"muteEveryoneElsesDesktopDialog": "Sobald die Bildschirmfreigaben beendet sind, können Sie diese nicht mehr starten, aber die anderen können sie jederzeit wieder starten.",
"muteEveryoneElsesDesktopTitle": "Alle Bildschirmfreigaben außer {{whom}} beenden?",
"muteEveryoneElsesVideoDialog": "Sobald die Kamera für alle anderen Personen deaktiviert ist, können Sie diese nicht wieder für alle einschalten, die anderen Personen können ihre Kamera aber jederzeit wieder einschalten.",
"muteEveryoneElsesVideoTitle": "Die Kamera von allen außer {{whom}} ausschalten?",
"muteEveryoneSelf": "sich selbst",
"muteEveryoneStartMuted": "Alle beginnen von jetzt an stummgeschaltet",
"muteEveryoneTitle": "Alle stummschalten?",
"muteEveryonesDesktopDialog": "Die Anwesenden können ihren Bildschirm jederzeit freigeben.",
"muteEveryonesDesktopDialogModerationOn": "Die Anwesenden können jederzeit eine Anfrage zur Bildschirmfreigabe senden.",
"muteEveryonesDesktopTitle": "Alle Bildschirmfreigaben beenden?",
"muteEveryonesVideoDialog": "Sind Sie sicher, dass Sie die Kamera von allen Personen deaktivieren möchten? Sie können dies nicht wieder rückgängig machen, jede Personen kann ihre Kamera aber jederzeit wieder einschalten.",
"muteEveryonesVideoDialogModerationOn": "Die Anwesenden können jederzeit eine Anfrage senden, um ihre Kamera einzuschalten.",
"muteEveryonesVideoDialogOk": "deaktivieren",
"muteEveryonesVideoTitle": "Die Kamera von allen anderen ausschalten?",
"muteParticipantBody": "Sie können die Stummschaltung anderer Personen nicht aufheben, aber eine Person kann ihre eigene Stummschaltung jederzeit beenden.",
"muteParticipantButton": "Stummschalten",
"muteParticipantsDesktopBody": "Sie können Bildschirmfreigaben von anderen nicht starten, sie können dies aber jederzeit.",
"muteParticipantsDesktopBodyModerationOn": "Weder Sie noch andere Anwesende können Bildschirmfreigaben starten.",
"muteParticipantsDesktopButton": "Bildschirmfreigabe beenden",
"muteParticipantsDesktopDialog": "Sind Sie sicher, dass Sie die Bildschirmfreigabe von dieser Person beenden möchten? Sie können diese nicht mehr starten, die Person aber jederzeit.",
"muteParticipantsDesktopDialogModerationOn": "Sind Sie sicher, dass Sie die Bildschirmfreigabe von dieser Person beenden möchten? Weder Sie noch die Person kann die Bildschirmfreigabe wieder starten.",
"muteParticipantsDesktopTitle": "Bildschirmfreigabe von dieser Person beenden?",
"muteParticipantsVideoBody": "Sie können die Kamera nicht wieder einschalten, die Person kann ihre Kamera aber jederzeit wieder einschalten.",
"muteParticipantsVideoBodyModerationOn": "Sie können die Kamera nicht wieder aktivieren und die Person selbst auch nicht.",
"muteParticipantsVideoButton": "Kamera ausschalten",
@@ -499,6 +533,7 @@
"tokenAuthFailedWithReasons": "Teilnahme an der Konferenz fehlgeschlagen. Möglicher Grund: {{reason}}",
"tokenAuthUnsupported": "Token-Authentifizierung wird nicht unterstützt.",
"transcribing": "Wird transkribiert",
"unauthenticatedAccessDisabled": "Zur Teilnahme an dieser Konferenz müssen Sie sich anmelden.",
"unlockRoom": "Konferenz$t(lockRoomPassword) entfernen",
"user": "Anmeldename",
"userIdentifier": "Benutzername",
@@ -539,6 +574,25 @@
"veryBad": "Sehr schlecht",
"veryGood": "Sehr gut"
},
"fileSharing": {
"downloadFailedDescription": "Bitte nochmals versuchen.",
"downloadFailedTitle": "Download fehlgeschlagen",
"downloadFile": "Download",
"downloadStarted": "Download gestartet",
"dragAndDrop": "Dateien hier oder irgendwo auf dem Bildschirm loslassen",
"fileAlreadyUploaded": "Datei wurde schon zur Konferenz hochgeladen.",
"fileRemovedByOther": "Ihre Datei '{{ fileName }}' wurde entfernt",
"fileTooLargeDescription": "Bitte stellen Sie sicher, dass Ihre Datei nicht die Maximalgröße von {{ maxFileSize }} überschreitet.",
"fileTooLargeTitle": "Die ausgewählte Datei ist zu groß",
"fileUploadProgress": "Datei wird hochgeladen",
"fileUploadedSuccessfully": "Datei erfolgreich hochgeladen",
"newFileNotification": "{{ participantName }} hat Datei '{{ fileName }}' hochgeladen",
"removeFile": "Entfernen",
"removeFileSuccess": "Datei erfolgreich entfernt",
"uploadFailedDescription": "Bitte versuchen Sie es erneut.",
"uploadFailedTitle": "Dateiupload fehlgeschlagen",
"uploadFile": "Datei hochladen"
},
"filmstrip": {
"accessibilityLabel": {
"heading": "Videominiaturen"
@@ -707,7 +761,8 @@
"notificationTitle": "Lobby",
"passwordJoinButton": "Beitreten",
"title": "Lobby",
"toggleLabel": "Lobby aktivieren"
"toggleLabel": "Lobby aktivieren",
"waitForModerator": "Die Konferenz wurde noch nicht gestartet, da noch keine Moderation anwesend ist. Wenn Sie zur Moderation gehören, melden Sie sich bitte an, ansonsten warten Sie bitte."
},
"localRecording": {
"clientState": {
@@ -750,8 +805,9 @@
"me": "ich",
"notify": {
"OldElectronAPPTitle": "Sicherheitslücke!",
"allowAll": "Alles einschalten",
"allowAudio": "Mikrofon einschalten",
"allowBoth": "Beides",
"allowDesktop": "Bildschirmfreigabe einschalten",
"allowVideo": "Kamera einschalten",
"allowedUnmute": "Sie können die Stummschaltung aufheben, Ihre Kamera einschalten oder Ihren Bildschirm teilen.",
"audioUnmuteBlockedDescription": "Díe Stummschaltung kann aus Überlastungsschutzgründen temporär nicht aufgehoben werden.",
@@ -765,6 +821,7 @@
"dataChannelClosedDescription": "Die Steuerungsverbindung (Bridge Channel) wurde unterbrochen, daher ist die Videoqulität auf die schlechteste Stufe limitiert.",
"dataChannelClosedDescriptionWithAudio": "Die Steuerungsverbindung (Bridge Channel) wurde unterbrochen, daher können Video- und Tonprobleme auftreten.",
"dataChannelClosedWithAudio": "Ton- und Videoqualität können beeinträchtigt sein",
"desktopMutedRemotelyTitle": "Ihre Bildschirmfreigabe wurde von {{participantDisplayName}} gestoppt",
"disabledIframe": "Die Einbettung ist nur für Demo-Zwecke vorgesehen. Diese Konferenz wird in {{timeout}} Minuten beendet.",
"disabledIframeSecondaryNative": "Die Einbettung von {{domain}} ist nur für Demo-Zwecke vorgesehen. Diese Konferenz wird in {{timeout}} Minuten beendet.",
"disabledIframeSecondaryWeb": "Die Einbettung von {{domain}} ist nur für Demo-Zwecke vorgesehen. Diese Konferenz wird in {{timeout}} Minuten beendet. Bitte nutzen Sie <a href='{{jaasDomain}}' rel='noopener noreferrer' target='_blank'>Jitsi as a Service</a> für produktive Zwecke!",
@@ -822,6 +879,7 @@
"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": "!",
"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",
@@ -845,6 +903,7 @@
"suggestRecordingDescription": "Möchten Sie eine Aufzeichnung starten?",
"suggestRecordingTitle": "Konferenz aufzeichnen",
"unmute": "Stummschaltung aufheben",
"unmuteScreen": "Bildschirmfreigabe starten",
"unmuteVideo": "Kamera einschalten",
"videoMutedRemotelyDescription": "Sie können sie jederzeit wieder einschalten.",
"videoMutedRemotelyTitle": "Ihre Kamera wurde von {{participantDisplayName}} ausgeschaltet!",
@@ -864,11 +923,14 @@
"admit": "Zulassen",
"admitAll": "Alle zulassen",
"allow": "Anwesenden erlauben:",
"allowDesktop": "Bildschirm freizugeben",
"allowVideo": "Kamera einschalten",
"askDesktop": "Anfragen, Bildschirm freizugeben",
"askUnmute": "Anfragen, Stummschaltung aufzuheben",
"audioModeration": "Für sich selbst die Stummschaltung aufzuheben",
"blockEveryoneMicCamera": "Kamera und Mikrofon von allen sperren",
"breakoutRooms": "Breakout-Räume",
"desktopModeration": "Bildschirmfreigabe",
"goLive": "Live gehen",
"invite": "Person einladen",
"lowerAllHands": "Alle Hände senken",
@@ -880,6 +942,8 @@
"muteAll": "Alle stummschalten",
"muteEveryoneElse": "Alle anderen stummschalten",
"reject": "Ablehnen",
"stopDesktop": "Bildschirmfreigabe beenden",
"stopEveryonesDesktop": "Alle Bildschirmfreigaben beenden",
"stopEveryonesVideo": "Alle Kameras ausschalten",
"stopVideo": "Kamera ausschalten",
"unblockEveryoneMicCamera": "Kamera und Mikrofon von allen entsperren",
@@ -889,9 +953,11 @@
"headings": {
"lobby": "Lobby ({{count}})",
"participantsList": "Anwesende ({{count}})",
"viewerRequests": "Zuschaueranfragen {{count}}",
"visitorInQueue": " (Wartende Gäste {{count}})",
"visitorRequests": " (Anfragen {{count}})",
"visitors": "Gäste ({{count}})",
"visitorsList": "Zuschauer ({{count}})",
"waitingLobby": "In der Lobby ({{count}})"
},
"search": "Suche Anwesende",
@@ -912,6 +978,9 @@
"by": "Von {{ name }}",
"closeButton": "Umfrage schließen",
"create": {
"accessibilityLabel": {
"send": "Umfrage erstellen"
},
"addOption": "Antwort hinzufügen",
"answerPlaceholder": "Antwort {{index}}",
"cancel": "Abbrechen",
@@ -920,8 +989,7 @@
"pollQuestion": "Frage",
"questionPlaceholder": "Eine Frage stellen",
"removeOption": "Antwort entfernen",
"save": "Erstellen",
"send": "Senden"
"save": "Erstellen"
},
"errors": {
"notUniqueOption": "Optionen müssen einzigartig sein"
@@ -1122,7 +1190,7 @@
"signedIn": "Momentan wird auf Kalendertermine von {{email}} zugegriffen. Klicken Sie auf die folgende Schaltfläche „Trennen“, um den Zugriff auf die Kalendertermine zu stoppen.",
"title": "Kalender"
},
"chatWithPermissions": "Chat mit Freigaben",
"chatWithPermissions": "Chat nur für Moderation erlauben",
"desktopShareFramerate": "Framerate für Bildschirmfreigabe",
"desktopShareHighFpsWarning": "Eine höhere Framerate könnte sich auf Ihre Datenrate auswirken. Sie müssen die Bildschirmfreigabe neustarten, damit die Einstellung übernommen wird.",
"desktopShareWarning": "Sie müssen die Bildschirmfreigabe neustarten, damit die Einstellung übernommen wird.",
@@ -1328,6 +1396,20 @@
"videounmute": "Kamera einschalten"
},
"addPeople": "Personen zur Konferenz hinzufügen",
"advancedAudioSettings": {
"aec": {
"label": "Echounterdrückung"
},
"agc": {
"label": "Automatische Mikrofonlautstärke"
},
"ns": {
"label": "Rauschunterdrückung"
},
"stereo": {
"label": "Stereo"
}
},
"audioOnlyOff": "Modus „Nur Audio“ deaktivieren",
"audioOnlyOn": "Modus „Nur Audio“ aktivieren",
"audioRoute": "Audiogerät auswählen",
@@ -1355,6 +1437,7 @@
"exitFullScreen": "Vollbildmodus verlassen",
"exitTileView": "Kachelansicht ausschalten",
"feedback": "Feedback hinterlassen",
"fileSharing": "Dateien",
"giphy": "GIPHY ein-/ausschalten",
"hangup": "Konferenz verlassen",
"help": "Hilfe",
@@ -1390,6 +1473,7 @@
"openReactionsMenu": "Interaktionsmenü öffnen",
"participants": "Anwesende",
"pip": "Bild-in-Bild-Modus einschalten",
"polls": "Umfragen",
"privateMessage": "Private Nachricht senden",
"profile": "Profil bearbeiten",
"raiseHand": "Hand heben",
@@ -1399,6 +1483,7 @@
"reactionHeart": "Herz senden",
"reactionLaugh": "Lachen senden",
"reactionLike": "Daumen hoch senden",
"reactionLove": "Liebe senden",
"reactionSilence": "Stille senden",
"reactionSurprised": "Überrascht senden",
"reactions": "Interaktionen",
@@ -1484,7 +1569,8 @@
"connectionInfo": "Verbindungsinformationen",
"demote": "Zu Gästen verschieben",
"domute": "Stummschalten",
"domuteDesktopOfOthers": "Bildschirm freigeben für alle beenden",
"domuteDesktop": "Bildschirmfreigabe beenden",
"domuteDesktopOfOthers": "Bildschirmfreigabe für alle beenden",
"domuteOthers": "Alle anderen stummschalten",
"domuteVideo": "Kamera ausschalten",
"domuteVideoOfOthers": "Alle anderen Kameras auschalten",
@@ -1549,6 +1635,8 @@
"noMainParticipantsTitle": "Diese Konferenz wurde noch nicht gestartet.",
"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.",
"title": "Sie sind Gast in der Konferenz"
},
"waitingMessage": "Sie werden der Konferenz beitreten, sobald sie gestartet ist!"

View File

@@ -126,8 +126,16 @@
"messagebox": "Digita un messaggio",
"newMessages": "Nuovi messaggi",
"nickname": {
"featureChat": "la chat",
"featureClosedCaptions": "i sottotitoli",
"featureFileSharing": "la condivisione file",
"featurePolls": "i sondaggi",
"popover": "Scegli un nickname",
"title": "Inserisci un nickname per usare la chat",
"titleWith1Features": "Inserisci un nickname per usare {{feature1}}",
"titleWith2Features": "Inserisci un nickname per usare {{feature1}} e {{feature2}}",
"titleWith3Features": "Inserisci un nickname per usare {{feature1}}, {{feature2}} e {{feature3}}",
"titleWith4Features": "Inserisci un nickname per usare {{feature1}}, {{feature2}}, {{feature3}} e {{feature4}}",
"titleWithCC": "Inserisci un nickname per usare la chat e i sottotitoli",
"titleWithPolls": "Inserisci un nickname per usare la chat e i sondaggi",
"titleWithPollsAndCC": "Inserisci un nickname per usare la chat, i sondaggi e i sottotitoli",
@@ -159,7 +167,7 @@
"installExtensionText": "Installa un'estensione per integrare Google Calendar e Office 365"
},
"closedCaptionsTab": {
"emptyState": "Il contenuto dei sottotitoli sarà disponibile una volta che l'organizzatore lo attiverà",
"emptyState": "Il contenuto dei sottotitoli sarà disponibile una volta che un moderatore lo attiverà",
"startClosedCaptionsButton": "Attiva sottotitoli"
},
"connectingOverlay": {
@@ -283,9 +291,9 @@
"Submit": "Invia",
"Understand": "Accetto, mantieni microfono e videocamera disattivati per ora",
"UnderstandAndUnmute": "Accetto, riattiva microfono e videocamera",
"WaitForHostNoAuthMsg": "La riunione non è ancora iniziata perché nessun organizzatore si è ancora collegato. Si prega di attendere.",
"WaitingForHostButton": "Attendi l'organizzatore",
"WaitingForHostTitle": "In attesa dell'organizzatore…",
"WaitForHostNoAuthMsg": "La riunione non è ancora iniziata perché nessun moderatore si è ancora collegato. Si prega di attendere.",
"WaitingForHostButton": "Attendi un moderatore",
"WaitingForHostTitle": "In attesa di un moderatore…",
"Yes": "Sì",
"accessibilityLabel": {
"Cancel": "Annulla (chiudi dialogo)",
@@ -348,8 +356,8 @@
"error": "Errore",
"errorRoomCreationRestriction": "Hai provato ad accedere alla riunione troppo presto, torna tra un po'.",
"gracefulShutdown": "Il nostro servizio è al momento inattivo per manutenzione. Si prega di riprovare più tardi.",
"grantModeratorDialog": "Vuoi rendere relatore questo partecipante?",
"grantModeratorTitle": "Fai diventare relatore",
"grantModeratorDialog": "Desideri di concedere i permessi da moderatore a {{participantName}}?",
"grantModeratorTitle": "Concedi permessi da moderatore",
"hide": "Nascondi",
"hideShareAudioHelper": "Non mostrare più questa finestra",
"incorrectPassword": "Nome utente o password errati",
@@ -727,12 +735,12 @@
"emailField": "Inserisci il tuo indirizzo email",
"enableDialogPasswordField": "Imposta password (opzionale)",
"enableDialogSubmit": "Attiva",
"enableDialogText": "La sala d'attesa ti permette di proteggere la riunione concedendo l'accesso solo alle persone autorizzate da un organizzatore.",
"enableDialogText": "La sala d'attesa ti permette di proteggere la riunione concedendo l'accesso solo alle persone autorizzate da un moderatore.",
"enterPasswordButton": "Inserisci password riunione",
"enterPasswordTitle": "Inserisci la password per entrare nella riunione",
"errorMissingPassword": "Inserisci la password della riunione",
"invalidPassword": "Password errata",
"joinRejectedMessage": "La tua richiesta d'accesso è stata respinta da un organizzatore.",
"joinRejectedMessage": "La tua richiesta d'accesso è stata respinta da un moderatore.",
"joinRejectedTitle": "Richiesta d'accesso respinta.",
"joinTitle": "Entra nella riunione",
"joinWithPasswordMessage": "Tentativo di accesso con password in corso, si prega di attendere…",
@@ -754,7 +762,7 @@
"passwordJoinButton": "Entra",
"title": "Sala d'attesa",
"toggleLabel": "Attiva sala d'attesa",
"waitForModerator": "La riunione non è ancora iniziata, perché non è arrivato alcun organizzatore. Se vuoi diventarlo autenticati, altrimenti attendi."
"waitForModerator": "La riunione non è ancora iniziata, perché non è arrivato alcun moderatore. Se vuoi diventarlo autenticati, altrimenti si prega di attendere."
},
"localRecording": {
"clientState": {
@@ -772,11 +780,11 @@
"me": "Io",
"messages": {
"engaged": "Registrazione avviata.",
"finished": "La registrazione della sessione {{token}} è terminata. Invia il file della registrazione all'organizzatore.",
"finishedModerator": "La registrazione della sessione {{token}} è terminata. La registrazione della traccia è stata salvata. Chiedi ai partecipanti di inviare le loro registrazioni.",
"notModerator": "Non sei un relatore. Non puoi avviare o interrompere la registrazione."
"finished": "La registrazione della sessione {{token}} è terminata. Invia il file della registrazione al moderatore.",
"finishedModerator": "La registrazione della sessione {{token}} è terminata. La registrazione della traccia locale è stata salvata. Si prega di chiedere ai partecipanti di inviare le loro registrazioni.",
"notModerator": "Non sei un moderatore. Non puoi avviare o interrompere la registrazione."
},
"moderator": "Relatore",
"moderator": "Moderatore",
"no": "No",
"participant": "Partecipante",
"participantStats": "Statistiche partecipante",
@@ -824,7 +832,7 @@
"focusFail": "{{component}} non disponibile - nuovo tentativo tra {{ms}} sec",
"gifsMenu": "GIPHY",
"groupTitle": "Notifiche",
"hostAskedUnmute": "Il relatore ti chiede di intervenire.",
"hostAskedUnmute": "Il moderatore ti chiede di intervenire.",
"invalidTenant": "Nome non valido",
"invalidTenantHyphenDescription": "Il nome che hai scelto non è valido (inizia o finisce con '-').",
"invalidTenantLengthDescription": "Il nome che hai scelto è troppo lungo.",
@@ -846,17 +854,17 @@
"localRecordingStopped": "{{name}} ha smesso di registrare.",
"me": "Io",
"moderationInEffectCSDescription": "Alza la mano, se vuoi condividere lo schermo.",
"moderationInEffectCSTitle": "La condivisione schermo è stata bloccata dal relatore",
"moderationInEffectCSTitle": "La condivisione schermo è stata bloccata dal moderatore",
"moderationInEffectDescription": "Alza la mano, se vuoi prendere la parola.",
"moderationInEffectTitle": "Il tuo microfono è stato spento dal relatore",
"moderationInEffectTitle": "Il tuo microfono è stato spento dal moderatore",
"moderationInEffectVideoDescription": "Alza la mano, se vuoi avviare la tua videocamera.",
"moderationInEffectVideoTitle": "La tua videocamera è stata spenta dal relatore",
"moderationRequestFromModerator": "Il relatore vorrebbe che tu accendessi il microfono",
"moderationInEffectVideoTitle": "La tua videocamera è stata spenta dal moderatore",
"moderationRequestFromModerator": "L'organizzatore ti chiede di accendere il microfono",
"moderationRequestFromParticipant": "Vuole parlare",
"moderationStartedTitle": "Moderazione in corso",
"moderationStoppedTitle": "Moderazione interrotta",
"moderationToggleDescription": "da {{participantDisplayName}}",
"moderator": "Ora sei un relatore!",
"moderator": "Ora sei un moderatore!",
"muted": "Hai iniziato la conversazione con il microfono disattivato.",
"mutedRemotelyDescription": "Puoi sempre attivare il microfono quando vuoi parlare. Spegni il microfono quando hai finito, per evitare rumori di fondo nella riunione.",
"mutedRemotelyTitle": "{{participantDisplayName}} ti ha spento il microfono",
@@ -1162,7 +1170,7 @@
},
"security": {
"about": "Puoi aggiungere una $t(lockRoomPassword) alla riunione. I partecipanti dovranno inserire la $t(lockRoomPassword) per accedere alla riunione.",
"aboutReadOnly": "Gli organizzatori possono aggiungere una $t(lockRoomPassword) alla riunione. I partecipanti dovranno inserire la $t(lockRoomPassword) per accedere alla riunione.",
"aboutReadOnly": "I moderatori possono aggiungere una $t(lockRoomPassword) alla riunione. I partecipanti dovranno inserire la $t(lockRoomPassword) per accedere alla riunione.",
"insecureRoomNameWarningNative": "Il nome della riunione è troppo semplice. Partecipanti non desiderati potrebbero entrare nella riunione. {{recommendAction}} Per saperne di più sulla sicurezza della tua riunione ",
"insecureRoomNameWarningWeb": "Il nome della riunione è troppo semplice. Partecipanti non desiderati potrebbero entrare nella riunione. {{recommendAction}} Per saperne di più sulla sicurezza della tua riunione <a href=\"{{securityUrl}}\" rel=\"security\" target=\"_blank\">clicca qui</a>.",
"title": "Impostazioni di sicurezza",
@@ -1182,7 +1190,7 @@
"signedIn": "Accesso agli eventi del calendario per {{email}} in corso. Clicca sul pulsante Disconnetti per interrompere laccesso agli eventi sul calendario.",
"title": "Calendario"
},
"chatWithPermissions": "La chat richiede il permesso",
"chatWithPermissions": "Disattiva la chat per i partecipanti",
"desktopShareFramerate": "Frequenza di aggiornamento condivisone schermo",
"desktopShareHighFpsWarning": "Una frequenza di aggiornamento della condivisione dello schermo più alta può influire sulla tua connessione. Devi riavviare la condivisione schermo, per applicare le modifiche.",
"desktopShareWarning": "Devi riavviare la condivisione schermo, per applicare le modifiche.",
@@ -1195,8 +1203,8 @@
"loggedIn": "Connesso come {{name}}",
"maxStageParticipants": "Numero massimo di partecipanti che possono essere messi in evidenza nella schermata principale",
"microphones": "Microfoni",
"moderator": "Relatore",
"moderatorOptions": "Opzioni relatore",
"moderator": "Moderatore",
"moderatorOptions": "Opzioni moderatore",
"more": "Generali",
"name": "Nome",
"noDevice": "Nessuno",
@@ -1325,7 +1333,7 @@
"feedback": "Lascia un feedback",
"fullScreen": "Attiva modalità a schermo intero",
"giphy": "Mostra menu GIPHY",
"grantModerator": "Concedi permessi da relatore",
"grantModerator": "Concedi permessi da moderatore",
"hangup": "Lascia la riunione",
"heading": "Barra degli strumenti",
"help": "Aiuto",
@@ -1350,7 +1358,7 @@
"muteEveryoneElsesVideoStream": "Spegni la videocamera a tutti gli altri",
"muteEveryonesVideoStream": "Spegni la videocamera a tutti",
"muteGUMPending": "Connessione del microfono in corso",
"noiseSuppression": "Cancellazione del rumore (BETA)",
"noiseSuppression": "Cancellazione del rumore",
"openChat": "Apri chat",
"participants": "Apri pannello partecipanti. {{participantsCount}} partecipanti",
"pip": "Attiva modalità Picture-in-Picture",
@@ -1415,20 +1423,21 @@
"closeParticipantsPane": "Chiudi pannello partecipanti",
"closeReactionsMenu": "Chiudi menu reazioni",
"closedCaptions": "Sottotitoli",
"disableNoiseSuppression": "Disattiva cancellazione del rumore (BETA)",
"disableNoiseSuppression": "Disattiva cancellazione del rumore",
"disableReactionSounds": "Puoi disattivare i suoni delle reazioni in questa riunione",
"documentClose": "Chiudi documento condiviso",
"documentOpen": "Apri documento condiviso",
"download": "Scarica le nostre app",
"e2ee": "Crittografia End-to-End",
"embedMeeting": "Incorpora riunione",
"enableNoiseSuppression": "Attiva cancellazione del rumore (BETA)",
"enableNoiseSuppression": "Attiva cancellazione del rumore",
"endConference": "Termina la riunione per tutti",
"enterFullScreen": "Mostra a schermo intero",
"enterTileView": "Mostra vista a mosaico",
"exitFullScreen": "Esci dalla modalità a schermo intero",
"exitTileView": "Esci dalla vista a mosaico",
"feedback": "Lascia un feedback",
"fileSharing": "Condivisione file",
"giphy": "Menu GIPHY",
"hangup": "Lascia la riunione",
"help": "Aiuto",
@@ -1457,13 +1466,14 @@
"noAudioSignalDialInDesc": "Puoi anche chiamare usando:",
"noAudioSignalDialInLinkDesc": "Numeri di telefono",
"noAudioSignalTitle": "Nessun suono rilevato dal tuo microfono!",
"noiseSuppression": "Cancellazione del rumore (BETA)",
"noiseSuppression": "Cancellazione del rumore",
"noisyAudioInputDesc": "Sembra che il tuo microfono faccia rumore, si prega di spegnerlo o cambiarlo.",
"noisyAudioInputTitle": "Il tuo microfono sembra fare rumore!",
"openChat": "Apri chat",
"openReactionsMenu": "Apri il menu reazioni",
"participants": "Partecipanti",
"pip": "Abilita modalità Picture-in-Picture",
"polls": "Sondaggi",
"privateMessage": "Invia un messaggio privato",
"profile": "Modifica profilo",
"raiseHand": "Alza la mano",
@@ -1565,11 +1575,11 @@
"domuteVideo": "Disattiva videocamera",
"domuteVideoOfOthers": "Disattiva videocamera a tutti gli altri",
"flip": "Specchia",
"grantModerator": "Concedi permessi da relatore",
"grantModerator": "Concedi permessi da moderatore",
"hideSelfView": "Nascondi la tua immagine",
"kick": "Espelli",
"mirrorVideo": "Specchia il tuo video",
"moderator": "Relatore",
"moderator": "Moderatore",
"mute": "Il partecipante ha il microfono spento",
"muted": "Microfono spento",
"pinToStage": "Metti in primo piano",
@@ -1616,7 +1626,7 @@
"description": "Adesso sei uno spettatore in questa riunione.",
"raiseHand": "Alza la mano",
"title": "Ingresso nella riunione in corso",
"wishToSpeak": "Se vuoi parlare, si prega di alzare la mano sotto e aspettare l'autorizzazione del relatore."
"wishToSpeak": "Per parlare si prega di alzare la mano sotto e aspettare l'autorizzazione del moderatore."
},
"labelTooltip": "Numero di spettatori: {{count}}",
"notification": {
@@ -1626,7 +1636,7 @@
"noVisitorLobby": "Non puoi partecipare se la sala d'attesa è attiva per la riunione.",
"notAllowedPromotion": "Un partecipante deve autorizzare la tua richiesta prima.",
"requestToJoin": "Mano alzata",
"requestToJoinDescription": "La tua richiesta è stata inviata ai relatori. Tieni duro!",
"requestToJoinDescription": "La tua richiesta è stata inviata ai moderatori. Tieni duro!",
"title": "Sei uno spettatore nella riunione"
},
"waitingMessage": "Ti unirai alla riunione quando inizierà!"
@@ -1667,7 +1677,7 @@
"mobileDownLoadLinkAndroid": "Scarica applicazione per Android",
"mobileDownLoadLinkFDroid": "Scarica applicazione da F-Droid",
"mobileDownLoadLinkIos": "Scarica applicazione per iOS",
"moderatedMessage": "O <a href=\"{{url}}\" rel=\"noopener noreferrer\" target=\"_blank\">prepara un URL di riunione</a> in anticipo, quando sei l'unico organizzatore.",
"moderatedMessage": "O <a href=\"{{url}}\" rel=\"noopener noreferrer\" target=\"_blank\">prepara un URL di riunione</a> in anticipo, quando sei l'unico moderatore.",
"privacy": "Privacy",
"recentList": "Recenti",
"recentListDelete": "Cancella",

1703
lang/main-kk.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -114,6 +114,10 @@
"error": "Erro: a sua mensagem não foi enviada. Motivo: {{error}}",
"everyone": "Todos",
"fieldPlaceHolder": "Aa",
"fileAccessibleTitle": "{{user}} carregou um ficheiro",
"fileAccessibleTitleMe": "carreguei um arquivo",
"fileDeleted": "Um ficheiro foi eliminado",
"guestsChatIndicator": "(convidado)",
"lobbyChatMessageTo": "Mensagem de chat na sala de espera para {{recipient}}",
"message": "Mensagem",
"messageAccessibleTitle": "{{user}} disse:",
@@ -122,8 +126,16 @@
"messagebox": "Escreva uma mensagem",
"newMessages": "Novas mensagens",
"nickname": {
"featureChat": "chat",
"featureClosedCaptions": "legendas ocultas",
"featureFileSharing": "partilha de ficheiros",
"featurePolls": "sondagens",
"popover": "Escolha um apelido",
"title": "Introduza um apelido para usar o chat",
"titleWith1Features": "Insira um apelido para usar {{feature1}}",
"titleWith2Features": "Insira um apelido para usar {{feature1}} e {{feature2}}",
"titleWith3Features": "Insira um apelido para usar {{feature1}}, {{feature2}} e {{feature3}}",
"titleWith4Features": "Insira um apelido para usar {{feature1}}, {{feature2}}, {{feature3}} e {{feature4}}",
"titleWithCC": "Insira um apelido para usar o chat e as legendas ocultas",
"titleWithPolls": "Digite um apelido para usar o chat e as sondagens",
"titleWithPollsAndCC": "Insira um apelido para utilizar o chat, as sondagens e as legendas ocultas",
@@ -279,7 +291,6 @@
"Submit": "Submeter",
"Understand": "Entendo, mantenha-me em silêncio por enquanto.",
"UnderstandAndUnmute": "Entendo, por favor, desative o silêncio.",
"WaitForHostMsg": "A conferência ainda não começou porque ainda não chegaram moderadores. Se quiser ser um moderador, inicie a sessão. Caso contrário, aguarde.",
"WaitForHostNoAuthMsg": "A conferência ainda não começou porque ainda não chegaram os moderadores. Por favor, aguarde.",
"WaitingForHostButton": "Esperar pelo moderador",
"WaitingForHostTitle": "À espera de um moderador…",
@@ -522,6 +533,7 @@
"tokenAuthFailedWithReasons": "Lamentamos, mas não está autorizado a participar nesta chamada. Razões possíveis: {{reason}}",
"tokenAuthUnsupported": "O URL de token não é suportado.",
"transcribing": "Transcrição",
"unauthenticatedAccessDisabled": "Esta chamada requer autenticação. Por favor, inicie sessão para prosseguir.",
"unlockRoom": "Retirar reunião $t(lockRoomPassword)",
"user": "Utilizador",
"userIdentifier": "Identificador do utilizador",
@@ -566,13 +578,17 @@
"downloadFailedDescription": "Por favor, tente novamente.",
"downloadFailedTitle": "Falha no descarregar",
"downloadFile": "Descarregar",
"downloadStarted": "O download do ficheiro foi iniciado",
"dragAndDrop": "Arraste e solte os ficheiros aqui ou em qualquer lugar do ecrã",
"fileAlreadyUploaded": "O ficheiro já foi carregado para esta reunião.",
"fileRemovedByOther": "O seu ficheiro '{{ fileName }}' foi removido",
"fileTooLargeDescription": "Certifique-se de que o ficheiro não exceda {{ maxFileSize }}.",
"fileTooLargeTitle": "O ficheiro selecionado é muito grande",
"fileUploadProgress": "Progresso do envio do ficheiro",
"fileUploadedSuccessfully": "Ficheiro carregado com sucesso",
"newFileNotification": "{{ participantName }} partilhou '{{ fileName }}'",
"removeFile": "Remover",
"removeFileSuccess": "Ficheiro removido com sucesso",
"uploadFailedDescription": "Por favor, tente novamente.",
"uploadFailedTitle": "Falha ao carregar",
"uploadFile": "Partilhar ficheiro"
@@ -745,7 +761,8 @@
"notificationTitle": "Sala de espera",
"passwordJoinButton": "Solicitar",
"title": "Sala de espera",
"toggleLabel": "Ativar sala de espera"
"toggleLabel": "Ativar sala de espera",
"waitForModerator": "A conferência ainda não começou porque não chegou nenhum moderador. Se deseja tornar-se um moderador, faça login. Caso contrário, aguarde."
},
"localRecording": {
"clientState": {
@@ -862,6 +879,7 @@
"oldElectronClientDescription1": "Parece estar a utilizar uma versão antiga do cliente Jitsi Meet que tem vulnerabilidades de segurança conhecidas. Por favor, certifique-se de que actualiza a nossa ",
"oldElectronClientDescription2": "compilação mais recente",
"oldElectronClientDescription3": " agora!",
"openChat": "Abrir chat",
"participantWantsToJoin": "Deseja juntar-se à reunião",
"participantsWantToJoin": "Desejam juntar-se à reunião",
"passwordRemovedRemotely": "$t(lockRoomPasswordUppercase) removido por outro participante",
@@ -960,6 +978,9 @@
"by": "Por {{ name }}",
"closeButton": "Fechar sondagem",
"create": {
"accessibilityLabel": {
"send": "Enviar sondagem"
},
"addOption": "Adicionar opção",
"answerPlaceholder": "Opção {{index}}",
"cancel": "Cancelar",
@@ -968,8 +989,7 @@
"pollQuestion": "Pergunta de Sondagem",
"questionPlaceholder": "Faça uma pergunta",
"removeOption": "Remover opção",
"save": "Guardar",
"send": "Enviar"
"save": "Guardar"
},
"errors": {
"notUniqueOption": "As opções devem ser únicas"
@@ -1338,7 +1358,7 @@
"muteEveryoneElsesVideo": "Parar o vídeo de todos os outros",
"muteEveryonesVideo": "Parar o vídeo de todos",
"muteGUMPending": "A ligar o seu microfone",
"noiseSuppression": "Supressão extra de ruído (BETA)",
"noiseSuppression": "Supressão extra de ruído",
"openChat": "Abrir chat",
"participants": "Abrir painel de participantes. {{participantsCount}} participantes",
"pip": "Mudar para o modo Picture-in-Picture",
@@ -1376,6 +1396,20 @@
"videounmute": "Iniciar câmara"
},
"addPeople": "Adicione pessoas à sua chamada",
"advancedAudioSettings": {
"aec": {
"label": "Cancelamento de eco acústico"
},
"agc": {
"label": "Controlo automático de ganho"
},
"ns": {
"label": "Supressão de ruído"
},
"stereo": {
"label": "Estéreo"
}
},
"audioOnlyOff": "Desativar modo de largura de banda baixa",
"audioOnlyOn": "Ativar modo de largura de banda baixa",
"audioRoute": "Selecionar o dispositivo de som",
@@ -1389,20 +1423,21 @@
"closeParticipantsPane": "Fechar painel de participantes",
"closeReactionsMenu": "Fechar menu de reações",
"closedCaptions": "Legendas ocultas",
"disableNoiseSuppression": "Desativar supressão de ruído extra (BETA)",
"disableNoiseSuppression": "Desativar supressão de ruído extra",
"disableReactionSounds": "Pode desactivar os sons de reacção para esta reunião",
"documentClose": "Fechar documento partilhado",
"documentOpen": "Abrir documento partilhado",
"download": "Descarregar as nossas aplicações",
"e2ee": "Criptografia ponta a ponta",
"embedMeeting": "Incorporar reunião",
"enableNoiseSuppression": "Ativar supressão extra de ruído (BETA)",
"enableNoiseSuppression": "Ativar supressão extra de ruído",
"endConference": "Terminar reunião para todos",
"enterFullScreen": "Ver em ecrã completo",
"enterTileView": "Ver em quadrícula",
"exitFullScreen": "Sair de ecrã completo",
"exitTileView": "Sair de quadrícula",
"feedback": "Deixar comentários",
"fileSharing": "Partilha de ficheiros",
"giphy": "Ativar/Desativar o menu GIPHY",
"hangup": "Sair da reunião",
"help": "Ajuda",
@@ -1431,13 +1466,14 @@
"noAudioSignalDialInDesc": "Também pode marcar usando:",
"noAudioSignalDialInLinkDesc": "Números de marcação",
"noAudioSignalTitle": "Não há nenhuma entrada vinda do seu microfone!",
"noiseSuppression": "Supressão extra de ruído (BETA)",
"noiseSuppression": "Supressão extra de ruído",
"noisyAudioInputDesc": "Parece que o seu microfone está a fazer barulho, por favor considere silenciar ou mudar de dispositivo.",
"noisyAudioInputTitle": "Seu microfone parece estar barulhento!",
"openChat": "Abrir chat",
"openReactionsMenu": "Abrir menu de reações",
"participants": "Participantes",
"pip": "Entrar no modo Picture-in-Picture",
"polls": "Sondagens",
"privateMessage": "Enviar mensagem privada",
"profile": "Editar o seu perfil",
"raiseHand": "Levantar a mão",
@@ -1447,6 +1483,7 @@
"reactionHeart": "Enviar reação com coração",
"reactionLaugh": "Enviar reação de risos",
"reactionLike": "Enviar reação de aprovado",
"reactionLove": "Enviar reação de amor",
"reactionSilence": "Enviar reação de silêncio",
"reactionSurprised": "Enviar reação de surpreendido",
"reactions": "Reações",
@@ -1598,6 +1635,8 @@
"noMainParticipantsTitle": "Esta reunião ainda não começou.",
"noVisitorLobby": "Não é possível aderir enquanto houver uma sala de espera activada para a reunião.",
"notAllowedPromotion": "É necessário que um participante autorize primeiro o seu pedido.",
"requestToJoin": "Mão levantada",
"requestToJoinDescription": "A sua solicitação foi enviada aos moderadores. Aguarde um momento!",
"title": "É um espectador na reunião"
},
"waitingMessage": "Participará na reunião assim que esta estiver em direto!"

View File

@@ -1190,7 +1190,7 @@
"signedIn": "Currently accessing calendar events for {{email}}. Click the Disconnect button below to stop accessing calendar events.",
"title": "Calendar"
},
"chatWithPermissions": "Chat requires permission",
"chatWithPermissions": "Disable chat for non-moderators",
"desktopShareFramerate": "Desktop sharing frame rate",
"desktopShareHighFpsWarning": "A higher frame rate for desktop sharing might affect your bandwidth. You need to restart the screen share for the new settings to take effect.",
"desktopShareWarning": "You need to restart the screen share for the new settings to take effect.",
@@ -1358,7 +1358,7 @@
"muteEveryoneElsesVideoStream": "Stop everyone else's video",
"muteEveryonesVideoStream": "Stop everyone's video",
"muteGUMPending": "Connecting your microphone",
"noiseSuppression": "Extra noise suppression (BETA)",
"noiseSuppression": "Extra noise suppression",
"openChat": "Open chat",
"participants": "Open participants panel. {{participantsCount}} participants",
"pip": "Toggle Picture-in-Picture mode",
@@ -1423,14 +1423,14 @@
"closeParticipantsPane": "Close participants pane",
"closeReactionsMenu": "Close reactions menu",
"closedCaptions": "Closed captions",
"disableNoiseSuppression": "Disable extra noise suppression (BETA)",
"disableNoiseSuppression": "Disable extra noise suppression",
"disableReactionSounds": "You can disable reaction sounds for this meeting",
"documentClose": "Close shared document",
"documentOpen": "Open shared document",
"download": "Download our apps",
"e2ee": "End-to-End Encryption",
"embedMeeting": "Embed meeting",
"enableNoiseSuppression": "Enable extra noise suppression (BETA)",
"enableNoiseSuppression": "Enable extra noise suppression",
"endConference": "End meeting for all",
"enterFullScreen": "View full screen",
"enterTileView": "Enter tile view",
@@ -1466,7 +1466,7 @@
"noAudioSignalDialInDesc": "You can also dial-in using:",
"noAudioSignalDialInLinkDesc": "Dial-in numbers",
"noAudioSignalTitle": "There is no input coming from your mic!",
"noiseSuppression": "Extra noise suppression (BETA)",
"noiseSuppression": "Extra noise suppression",
"noisyAudioInputDesc": "It sounds like your microphone is making noise, please consider muting or changing the device.",
"noisyAudioInputTitle": "Your microphone appears to be noisy!",
"openChat": "Open chat",

View File

@@ -107,9 +107,10 @@ import {
open as openParticipantsPane
} from '../../react/features/participants-pane/actions';
import { getParticipantsPaneOpen } from '../../react/features/participants-pane/functions';
import { hidePiP, showPiP } from '../../react/features/pip/actions';
import { startLocalVideoRecording, stopLocalVideoRecording } from '../../react/features/recording/actions.any';
import { grantRecordingConsent, grantRecordingConsentAndUnmute } from '../../react/features/recording/actions.web';
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../react/features/recording/constants';
import { RECORDING_TYPES } from '../../react/features/recording/constants';
import { getActiveSession, supportsLocalRecording } from '../../react/features/recording/functions';
import { startAudioScreenShareFlow, startScreenShareFlow } from '../../react/features/screen-share/actions';
import { isScreenAudioSupported } from '../../react/features/screen-share/functions';
@@ -125,7 +126,7 @@ import { extractYoutubeIdOrURL } from '../../react/features/shared-video/functio
import { setRequestingSubtitles, toggleRequestingSubtitles } from '../../react/features/subtitles/actions';
import { isAudioMuteButtonDisabled } from '../../react/features/toolbox/functions';
import { setTileView, toggleTileView } from '../../react/features/video-layout/actions.any';
import { muteAllParticipants } from '../../react/features/video-menu/actions';
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 { VIRTUAL_BACKGROUND_TYPE } from '../../react/features/virtual-background/constants';
@@ -238,6 +239,17 @@ function initCommands() {
APP.store.dispatch(muteAllParticipants(exclude, muteMediaType));
},
'mute-remote-participant': (participantId, mediaType) => {
if (!isLocalParticipantModerator(APP.store.getState())) {
logger.error('Missing moderator rights to mute remote participant');
return;
}
const muteMediaType = mediaType ? mediaType : MEDIA_TYPE.AUDIO;
APP.store.dispatch(muteRemote(participantId, muteMediaType));
},
'toggle-lobby': isLobbyEnabled => {
APP.store.dispatch(toggleLobbyMode(isLobbyEnabled));
},
@@ -778,10 +790,7 @@ function initCommands() {
}
if (transcription) {
APP.store.dispatch(setRequestingSubtitles(true, false, null, true));
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: true
});
APP.store.dispatch(setRequestingSubtitles(true, false, null));
}
},
@@ -804,9 +813,6 @@ function initCommands() {
if (transcription) {
APP.store.dispatch(setRequestingSubtitles(false, false, null));
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: false
});
}
if (mode === 'local') {
@@ -907,6 +913,12 @@ function initCommands() {
backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
virtualSource: backgroundImage
}, jitsiTrack));
},
'show-pip': () => {
APP.store.dispatch(showPiP());
},
'hide-pip': () => {
APP.store.dispatch(hidePiP());
}
};
transport.on('event', ({ data, name }) => {
@@ -1242,6 +1254,20 @@ class API {
});
}
/**
* Notify external application (if API is enabled) that the in-page toolbox
* visibility changed.
*
* @param {boolean} visible - True if the toolbox is visible, false otherwise.
* @returns {void}
*/
notifyToolbarVisibilityChanged(visible) {
this._sendEvent({
name: 'toolbar-visibility-changed',
visible
});
}
/**
* Notifies the external application (spot) that the local jitsi-participant
* has a status update.
@@ -1386,6 +1412,25 @@ class API {
});
}
/**
* Notify the external application that a participant's mute status changed.
*
* @param {string} participantId - The ID of the participant.
* @param {boolean} isMuted - True if muted, false if unmuted.
* @param {string} mediaType - Media type that was muted ('audio', 'video', or 'desktop').
* @param {boolean} isSelfMuted - True if participant muted themselves, false if muted by moderator.
* @returns {void}
*/
notifyParticipantMuted(participantId, isMuted, mediaType, isSelfMuted = true) {
this._sendEvent({
name: 'participant-muted',
id: participantId,
isMuted,
mediaType,
isSelfMuted
});
}
/**
* Notify the external app that a notification has been triggered.
*
@@ -2235,6 +2280,40 @@ class API {
});
}
/**
* Notify external application (if API is enabled) that Picture-in-Picture was requested.
* Used by Electron to handle PiP requests with proper user gesture context.
*
* @returns {void}
*/
notifyPictureInPictureRequested() {
this._sendEvent({
name: '_pip-requested'
});
}
/**
* Notify external application (if API is enabled) that Picture-in-Picture mode was entered.
*
* @returns {void}
*/
notifyPictureInPictureEntered() {
this._sendEvent({
name: 'pip-entered'
});
}
/**
* Notify external application (if API is enabled) that Picture-in-Picture mode was exited.
*
* @returns {void}
*/
notifyPictureInPictureLeft() {
this._sendEvent({
name: 'pip-left'
});
}
/**
* Notify external application ( if API is enabled) that a participant menu button was clicked.
*

View File

@@ -2,6 +2,7 @@ import { jitsiLocalStorage } from '@jitsi/js-utils/jitsi-local-storage';
import EventEmitter from 'events';
import { urlObjectToString } from '../../../react/features/base/util/uri';
import { isPiPEnabled } from '../../../react/features/pip/external-api.shared';
import {
PostMessageTransportBackend,
Transport
@@ -46,6 +47,7 @@ const commands = {
localSubject: 'local-subject',
kickParticipant: 'kick-participant',
muteEveryone: 'mute-everyone',
muteRemoteParticipant: 'mute-remote-participant',
overwriteConfig: 'overwrite-config',
overwriteNames: 'overwrite-names',
password: 'password',
@@ -94,7 +96,9 @@ const commands = {
toggleTileView: 'toggle-tile-view',
toggleVirtualBackgroundDialog: 'toggle-virtual-background',
toggleVideo: 'toggle-video',
toggleWhiteboard: 'toggle-whiteboard'
toggleWhiteboard: 'toggle-whiteboard',
showPiP: 'show-pip',
hidePiP: 'hide-pip'
};
/**
@@ -102,6 +106,9 @@ const commands = {
* events expected by jitsi-meet.
*/
const events = {
'_pip-requested': '_pipRequested',
'pip-entered': 'pipEntered',
'pip-left': 'pipLeft',
'avatar-changed': 'avatarChanged',
'audio-availability-changed': 'audioAvailabilityChanged',
'audio-mute-status-changed': 'audioMuteStatusChanged',
@@ -144,6 +151,7 @@ const events = {
'participant-joined': 'participantJoined',
'participant-kicked-out': 'participantKickedOut',
'participant-left': 'participantLeft',
'participant-muted': 'participantMuted',
'participant-role-changed': 'participantRoleChanged',
'participants-pane-toggled': 'participantsPaneToggled',
'password-required': 'passwordRequired',
@@ -167,6 +175,7 @@ const events = {
'suspend-detected': 'suspendDetected',
'tile-view-changed': 'tileViewChanged',
'toolbar-button-clicked': 'toolbarButtonClicked',
'toolbar-visibility-changed': 'toolbarVisibilityChanged',
'transcribing-status-changed': 'transcribingStatusChanged',
'transcription-chunk-received': 'transcriptionChunkReceived',
'whiteboard-status-changed': 'whiteboardStatusChanged'
@@ -329,6 +338,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this._myUserID = undefined;
this._onStageParticipant = undefined;
this._iAmvisitor = undefined;
this._pipConfig = configOverwrite?.pip;
this._setupListeners();
id++;
}
@@ -648,6 +658,56 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this.emit(requestName, data, callback);
}
});
this._setupIntersectionObserver();
}
/**
* Sets up IntersectionObserver to monitor iframe visibility.
* Calls showPiP/hidePiP based on visibility.
*
* @private
* @returns {void}
*/
_setupIntersectionObserver() {
if (!isPiPEnabled(this._pipConfig)) {
return;
}
// Don't create duplicate observers.
if (this._intersectionObserver) {
return;
}
this._isIntersecting = true;
this._intersectionObserver = new IntersectionObserver(entries => {
const entry = entries[entries.length - 1];
const wasIntersecting = this._isIntersecting;
this._isIntersecting = entry.isIntersecting;
if (!entry.isIntersecting && wasIntersecting) {
this.showPiP();
} else if (entry.isIntersecting && !wasIntersecting) {
this.hidePiP();
}
});
this._intersectionObserver.observe(this._frame);
}
/**
* Tears down IntersectionObserver.
*
* @private
* @returns {void}
*/
_teardownIntersectionObserver() {
if (this._intersectionObserver) {
this._intersectionObserver.disconnect();
this._intersectionObserver = null;
}
}
/**
@@ -850,6 +910,8 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this.emit('_willDispose');
this._transport.dispose();
this.removeAllListeners();
this._teardownIntersectionObserver();
if (this._frame && this._frame.parentNode) {
this._frame.parentNode.removeChild(this._frame);
}
@@ -878,10 +940,47 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
return;
}
// Handle pip config changes locally.
// We update local state, send command to iframe, then handle PiP show/hide
// so the iframe config is updated before we try to show PiP.
let pipTransition = null;
if (name === 'overwriteConfig' && args[0]?.pip !== undefined) {
const wasEnabled = isPiPEnabled(this._pipConfig);
this._pipConfig = {
...this._pipConfig,
...args[0].pip
};
const isEnabled = isPiPEnabled(this._pipConfig);
if (!wasEnabled && isEnabled) {
this._setupIntersectionObserver();
pipTransition = 'enabled';
} else if (wasEnabled && !isEnabled) {
this._teardownIntersectionObserver();
pipTransition = 'disabled';
}
}
// Send command to iframe first.
this._transport.sendEvent({
data: args,
name: commands[name]
});
// Handle PiP state after command is sent so iframe config is updated.
if (pipTransition === 'enabled') {
// Show PiP if iframe is currently not visible.
if (!this._isIntersecting) {
this.showPiP();
}
} else if (pipTransition === 'disabled') {
// Hide any open PiP window.
this.hidePiP();
}
}
/**
@@ -1495,6 +1594,24 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
this.executeCommand('setVirtualBackground', enabled, backgroundImage);
}
/**
* Shows Picture-in-Picture window.
*
* @returns {void}
*/
showPiP() {
this.executeCommand('showPiP');
}
/**
* Hides Picture-in-Picture window.
*
* @returns {void}
*/
hidePiP() {
this.executeCommand('hidePiP');
}
/**
* Opens the desktop picker. This is invoked by the Electron SDK when gDM is used.
*

3241
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -72,7 +72,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/v2113.0.0+ffcffaa7/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v2118.0.0+67fd2c84/lib-jitsi-meet.tgz",
"lodash-es": "4.17.21",
"null-loader": "4.0.1",
"optional-require": "1.0.3",
@@ -164,12 +164,12 @@
"@types/w3c-image-capture": "1.0.6",
"@types/w3c-web-hid": "1.0.3",
"@types/zxcvbn": "4.4.1",
"@wdio/allure-reporter": "9.16.0",
"@wdio/cli": "9.16.0",
"@wdio/globals": "9.16.0",
"@wdio/junit-reporter": "9.16.0",
"@wdio/local-runner": "9.16.0",
"@wdio/mocha-framework": "9.16.0",
"@wdio/allure-reporter": "9.22.0",
"@wdio/cli": "9.22.0",
"@wdio/globals": "9.17.0",
"@wdio/junit-reporter": "9.21.0",
"@wdio/local-runner": "9.22.0",
"@wdio/mocha-framework": "9.22.0",
"babel-loader": "9.1.0",
"babel-plugin-optional-require": "0.3.1",
"circular-dependency-plugin": "5.2.0",
@@ -193,7 +193,7 @@
"ts-loader": "9.4.2",
"typescript": "5.7.2",
"unorm": "1.6.0",
"webdriverio": "9.16.0",
"webdriverio": "9.22.0",
"webpack": "5.95.0",
"webpack-bundle-analyzer": "4.4.2",
"webpack-cli": "5.1.4",

View File

@@ -5,6 +5,7 @@ import JitsiThemeProvider from '../../base/ui/components/JitsiThemeProvider.web'
import DialogContainer from '../../base/ui/components/web/DialogContainer';
import ChromeExtensionBanner from '../../chrome-extension-banner/components/ChromeExtensionBanner.web';
import OverlayContainer from '../../overlay/components/web/OverlayContainer';
import PiP from '../../pip/components/PiP';
import { AbstractApp } from './AbstractApp';
@@ -47,6 +48,7 @@ export class App extends AbstractApp {
<JitsiThemeProvider>
<GlobalStyles />
<ChromeExtensionBanner />
<PiP />
{ super._createMainElement(component, props) }
</JitsiThemeProvider>
);

View File

@@ -2,7 +2,6 @@ import '../analytics/middleware';
import '../authentication/middleware';
import '../av-moderation/middleware';
import '../base/conference/middleware';
import '../base/config/middleware';
import '../base/i18n/middleware';
import '../base/jwt/middleware';
import '../base/known-domains/middleware';

View File

@@ -1,3 +1,4 @@
import '../base/config/middleware';
import '../dynamic-branding/middleware';
import '../gifs/middleware';
import '../mobile/audio-mode/middleware';

View File

@@ -1,4 +1,5 @@
import '../base/app/middleware';
import '../base/config/middleware';
import '../base/connection/middleware';
import '../base/devices/middleware';
import '../base/media/middleware';
@@ -11,6 +12,7 @@ import '../no-audio-signal/middleware';
import '../notifications/middleware';
import '../noise-detection/middleware';
import '../old-client-notification/middleware';
import '../pip/middleware';
import '../power-monitor/middleware';
import '../prejoin/middleware';
import '../remote-control/middleware';

View File

@@ -8,6 +8,7 @@ import '../keyboard-shortcuts/reducer';
import '../no-audio-signal/reducer';
import '../noise-detection/reducer';
import '../participants-pane/reducer';
import '../pip/reducer';
import '../power-monitor/reducer';
import '../prejoin/reducer';
import '../remote-control/reducer';

View File

@@ -57,6 +57,7 @@ import { INoiseDetectionState } from '../noise-detection/reducer';
import { INoiseSuppressionState } from '../noise-suppression/reducer';
import { INotificationsState } from '../notifications/reducer';
import { IParticipantsPaneState } from '../participants-pane/reducer';
import { IPipState } from '../pip/reducer';
import { IPollsState } from '../polls/reducer';
import { IPollsHistoryState } from '../polls-history/reducer';
import { IPowerMonitorState } from '../power-monitor/reducer';
@@ -145,6 +146,7 @@ export interface IReduxState {
'features/noise-suppression': INoiseSuppressionState;
'features/notifications': INotificationsState;
'features/participants-pane': IParticipantsPaneState;
'features/pip': IPipState;
'features/polls': IPollsState;
'features/polls-history': IPollsHistoryState;
'features/power-monitor': IPowerMonitorState;

View File

@@ -5,7 +5,18 @@ import Icon from '../../../icons/components/Icon';
import { pixelsToRem } from '../../../ui/functions.any';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
import { PRESENCE_AVAILABLE_COLOR, PRESENCE_AWAY_COLOR, PRESENCE_BUSY_COLOR, PRESENCE_IDLE_COLOR } from '../styles';
import {
PRESENCE_AVAILABLE_COLOR,
PRESENCE_AWAY_COLOR,
PRESENCE_BUSY_COLOR,
PRESENCE_IDLE_COLOR
} from '../styles';
import {
AVATAR_DEFAULT_BACKGROUND_COLOR,
getAvatarFont,
getAvatarInitialsColor
} from './styles';
interface IProps extends IAvatarProps {
@@ -48,10 +59,10 @@ interface IProps extends IAvatarProps {
const useStyles = makeStyles()(theme => {
return {
avatar: {
backgroundColor: '#AAA',
backgroundColor: AVATAR_DEFAULT_BACKGROUND_COLOR,
borderRadius: '50%',
color: theme.palette?.text01 || '#fff',
...(theme.typography?.heading1 ?? {}),
color: getAvatarInitialsColor(theme),
...getAvatarFont(theme),
fontSize: 'inherit',
objectFit: 'cover',
textAlign: 'center',

View File

@@ -0,0 +1,26 @@
import { Theme } from '@mui/material/styles';
// Default avatar background color
export const AVATAR_DEFAULT_BACKGROUND_COLOR = '#AAA';
/**
* Returns the avatar font style from the theme.
*
* @param {Theme} theme - The MUI theme.
* @returns {Object} The font style object containing fontFamily, fontWeight, etc.
*/
export const getAvatarFont = (theme: Theme) => theme.typography?.heading1 ?? {};
/**
* Default text color for avatar initials.
*/
export const AVATAR_DEFAULT_INITIALS_COLOR = '#FFFFFF';
/**
* Returns the text color for avatar initials from the theme.
*
* @param {Theme} theme - The MUI theme.
* @returns {string} The text color.
*/
export const getAvatarInitialsColor = (theme: Theme): string =>
theme.palette?.text01 || AVATAR_DEFAULT_INITIALS_COLOR;

View File

@@ -517,6 +517,10 @@ export interface IConfig {
peopleSearchQueryTypes?: string[];
peopleSearchTokenLocation?: string;
peopleSearchUrl?: string;
pip?: {
disabled?: boolean;
showOnPrejoin?: boolean;
};
preferBosh?: boolean;
preferVisitor?: boolean;
preferredTranscribeLanguage?: string;
@@ -555,6 +559,7 @@ export interface IConfig {
skipConsentInMeeting?: boolean;
suggestRecording?: boolean;
};
reducedUImainToolbarButtons?: Array<string>;
remoteVideoMenu?: {
disableDemote?: boolean;
disableGrantModerator?: boolean;
@@ -577,6 +582,7 @@ export interface IConfig {
};
serviceUrl?: string;
sharedVideoAllowedURLDomains?: Array<string>;
showChatPermissionsModeratorSetting?: boolean;
sipInviteUrl?: string;
speakerStats?: {
disableSearch?: boolean;
@@ -632,7 +638,6 @@ export interface IConfig {
autoTranscribeOnRecord?: boolean;
disableClosedCaptions?: boolean;
enabled?: boolean;
inviteJigasiOnBackendTranscribing?: boolean;
preferredLanguage?: string;
translationLanguages?: Array<string>;
translationLanguagesHead?: Array<string>;

View File

@@ -199,6 +199,7 @@ export default [
'participantMenuButtonsWithNotifyClick',
'participantsPane',
'pcStatsInterval',
'pip',
'preferBosh',
'preferVisitor',
'prejoinConfig.enabled',
@@ -214,10 +215,12 @@ export default [
'recordings.showPrejoinWarning',
'recordings.showRecordingLink',
'recordings.suggestRecording',
'reducedUImainToolbarButtons',
'replaceParticipant',
'resolution',
'screenshotCapture',
'securityUi',
'showChatPermissionsModeratorSetting',
'speakerStats',
'startAudioMuted',
'startAudioOnly',

View File

@@ -2,7 +2,6 @@ import { AnyAction } from 'redux';
import { IStore } from '../../app/types';
import { SET_DYNAMIC_BRANDING_DATA } from '../../dynamic-branding/actionTypes';
import { setUserFilmstripWidth } from '../../filmstrip/actions.web';
import { getFeatureFlag } from '../flags/functions';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { updateSettings } from '../settings/actions';
@@ -80,29 +79,8 @@ function _setConfig({ dispatch, getState }: IStore, next: Function, action: AnyA
}));
}
const { initialWidth, stageFilmstripParticipants } = action.config.filmstrip || {};
if (stageFilmstripParticipants !== undefined) {
dispatch(updateSettings({
maxStageParticipants: stageFilmstripParticipants
}));
}
if (initialWidth) {
dispatch(setUserFilmstripWidth(initialWidth));
}
dispatch(updateConfig(config));
// FIXME On Web we rely on the global 'config' variable which gets altered
// multiple times, before it makes it to the reducer. At some point it may
// not be the global variable which is being modified anymore due to
// different merge methods being used along the way. The global variable
// must be synchronized with the final state resolved by the reducer.
if (typeof window.config !== 'undefined') {
window.config = state['features/base/config'];
}
return result;
}

View File

@@ -0,0 +1 @@
import './middleware.any';

View File

@@ -0,0 +1,48 @@
import { setUserFilmstripWidth } from '../../filmstrip/actions.web';
import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { updateSettings } from '../settings/actions';
import { SET_CONFIG } from './actionTypes';
import './middleware.any';
/**
* The middleware of the feature {@code base/config}.
*
* @param {Store} store - The redux store.
* @private
* @returns {Function}
*/
MiddlewareRegistry.register(store => next => action => {
const result = next(action);
switch (action.type) {
case SET_CONFIG: {
const { initialWidth, stageFilmstripParticipants } = action.config.filmstrip || {};
const { dispatch, getState } = store;
const state = getState();
if (stageFilmstripParticipants !== undefined) {
dispatch(updateSettings({
maxStageParticipants: stageFilmstripParticipants
}));
}
if (initialWidth) {
dispatch(setUserFilmstripWidth(initialWidth));
}
// FIXME On Web we rely on the global 'config' variable which gets altered
// multiple times, before it makes it to the reducer. At some point it may
// not be the global variable which is being modified anymore due to
// different merge methods being used along the way. The global variable
// must be synchronized with the final state resolved by the reducer.
if (typeof window.config !== 'undefined') {
window.config = state['features/base/config'];
}
break;
}
}
return result;
});

View File

@@ -1,5 +1,6 @@
import React, { PureComponent, ReactNode } from 'react';
import { SafeAreaView, ScrollView, View, ViewStyle } from 'react-native';
import { ScrollView, View, ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { IStore } from '../../../../app/types';
@@ -122,6 +123,7 @@ class BottomSheet extends PureComponent<Props> {
style = { styles.sheetAreaCover } />
{ renderHeader?.() }
<SafeAreaView
edges = { [ 'left', 'right' ] }
style = { [
styles.sheetItemContainer,
renderHeader

View File

@@ -8,13 +8,14 @@ import i18next from './i18next';
*
* @param {string} language - The language e.g. 'en', 'fr'.
* @param {string} url - The url of the translation bundle.
* @param {string} ns - The namespace of the translation bundle.
* @returns {void}
*/
export async function changeLanguageBundle(language: string, url: string) {
export async function changeLanguageBundle(language: string, url: string, ns = 'main') {
const res = await fetch(url);
const bundle = await res.json();
i18next.addResourceBundle(language, 'main', bundle, true, true);
i18next.addResourceBundle(language, ns, bundle, true, true);
}
/**

View File

@@ -58,6 +58,14 @@ export const DEFAULT_LANGUAGE = 'en';
*/
export const TRANSLATION_LANGUAGES_HEAD: Array<string> = [ DEFAULT_LANGUAGE ];
/**
* The available/supported i18n namespaces.
*
* @public
* @type {Array<string>}
*/
export const SUPPORTED_NS = [ 'main', 'languages', 'countries', 'translation-languages' ];
/**
* The options to initialize i18next with.
*
@@ -81,7 +89,7 @@ const options: i18next.InitOptions = {
escapeValue: false // not needed for react as it escapes by default
},
load: 'all',
ns: [ 'main', 'languages', 'countries', 'translation-languages' ],
ns: SUPPORTED_NS,
react: {
// re-render when a new resource bundle is added
// @ts-expect-error. Fixed in i18next 19.6.1.

View File

@@ -4,7 +4,7 @@ import MiddlewareRegistry from '../redux/MiddlewareRegistry';
import { I18NEXT_INITIALIZED, LANGUAGE_CHANGED } from './actionTypes';
import { changeLanguageBundle } from './functions';
import i18next from './i18next';
import i18next, { SUPPORTED_NS } from './i18next';
import logger from './logger';
/**
@@ -19,9 +19,10 @@ MiddlewareRegistry.register(store => next => action => {
case LANGUAGE_CHANGED:
case SET_DYNAMIC_BRANDING_DATA: {
const { language } = i18next;
const { labels } = action.type === SET_DYNAMIC_BRANDING_DATA
const data = action.type === SET_DYNAMIC_BRANDING_DATA
? action.value
: store.getState()['features/dynamic-branding'];
const labels = data?.labels;
if (language && labels?.[language]) {
changeLanguageBundle(language, labels[language])
@@ -30,6 +31,17 @@ MiddlewareRegistry.register(store => next => action => {
});
}
SUPPORTED_NS.forEach(ns => {
const nsLabels = data?.[`labels-${ns}`];
if (language && nsLabels?.[language]) {
changeLanguageBundle(language, nsLabels[language], ns)
.catch(err => {
logger.log(`Error setting dynamic language bundle for ${ns}`, err);
});
}
});
// Update transcription language, if applicable.
if (action.type === SET_DYNAMIC_BRANDING_DATA) {
const { defaultTranscriptionLanguage } = action.value;

View File

@@ -10,6 +10,10 @@ import { parseURLParams } from '../util/parseURLParams';
import { JWT_VALIDATION_ERRORS, MEET_FEATURES } from './constants';
import logger from './logger';
/**
* Note that this is just client-side code and it intentionally does not verify the signature of the JWT.
*/
/**
* Retrieves the JSON Web Token (JWT), if any, defined by a specific
* {@link URL}.

View File

@@ -80,3 +80,8 @@ export const LOWER_HAND_AUDIO_LEVEL = 0.2;
* Icon URL for the whiteboard participant.
*/
export const WHITEBOARD_PARTICIPANT_ICON = IconWhiteboard;
/**
* The ID used for non-participant (system) messages coming from a transcriber.
*/
export const TRANSCRIBER_ID = 'transcriber';

View File

@@ -818,7 +818,7 @@ export const addPeopleFeatureControl = (stateful: IStateful) => {
* @param {Function} dispatch - The Redux dispatch function.
* @returns {Function}
*/
export const setShareDialogVisiblity = (addPeopleFeatureEnabled: boolean, dispatch: IStore['dispatch']) => {
export const setShareDialogVisiblity = (addPeopleFeatureEnabled: boolean | undefined, dispatch: IStore['dispatch']) => {
if (addPeopleFeatureEnabled) {
dispatch(toggleShareDialog(false));
} else {

View File

@@ -22,17 +22,35 @@ export function preloadImage(
return new Promise((resolve, reject) => {
const image = document.createElement('img');
// Cleanup function to release resources and prevent memory leaks
const cleanup = () => {
// Clear event handlers to break circular references
image.onload = null;
image.onerror = null;
// Clear src to stop any pending load and allow GC
image.src = '';
};
if (useCORS) {
image.setAttribute('crossOrigin', '');
}
image.onload = () => resolve({
src,
isUsingCORS: useCORS
});
image.onload = () => {
cleanup();
resolve({
src,
isUsingCORS: useCORS
});
};
image.onerror = error => {
cleanup();
if (tryOnce) {
reject(error);
} else {
// Retry with different CORS mode
preloadImage(src, !useCORS, true)
.then(resolve)
.catch(reject);

View File

@@ -1,10 +1,10 @@
import React, { Component } from 'react';
import {
SectionList as ReactNativeSectionList,
SafeAreaView,
SectionListRenderItemInfo,
ViewStyle
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Item, Section } from '../../types';
@@ -76,6 +76,7 @@ export default class SectionList extends Component<IProps> {
override render() {
return (
<SafeAreaView
edges = { [ 'left', 'right' ] }
style = { styles.container as ViewStyle } >
<ReactNativeSectionList
ListEmptyComponent = { this.props.ListEmptyComponent }

View File

@@ -24,6 +24,7 @@ import { ASPECT_RATIO_NARROW, ASPECT_RATIO_WIDE } from './constants';
* determine whether and how to render it.
*/
const REDUCED_UI_THRESHOLD = 300;
const WEB_REDUCED_UI_THRESHOLD = 320;
/**
* Indicates a resize of the window.
@@ -49,6 +50,8 @@ export function clientResized(clientWidth: number, clientHeight: number) {
}
availableWidth -= getParticipantsPaneWidth(state);
dispatch(setReducedUI(availableWidth, clientHeight));
}
batch(() => {
@@ -106,7 +109,10 @@ export function setAspectRatio(width: number, height: number) {
*/
export function setReducedUI(width: number, height: number) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const reducedUI = Math.min(width, height) < REDUCED_UI_THRESHOLD;
const threshold = navigator.product === 'ReactNative'
? REDUCED_UI_THRESHOLD
: WEB_REDUCED_UI_THRESHOLD;
const reducedUI = Math.min(width, height) < threshold;
if (reducedUI !== getState()['features/base/responsive-ui'].reducedUI) {
return dispatch({

View File

@@ -29,6 +29,7 @@ import {
TRACK_UPDATED,
TRACK_WILL_CREATE
} from './actionTypes';
import { toggleScreensharing } from './actions';
import {
createLocalTracksF,
getCameraFacingMode,
@@ -385,8 +386,22 @@ export function trackAdded(track: any) {
const mediaType = track.getVideoType() === VIDEO_TYPE.DESKTOP
? MEDIA_TYPE.SCREENSHARE
: track.getType();
let isReceivingData, noDataFromSourceNotificationInfo, participantId;
// Make screen share toggle off listen to MediaStreamTrack "ended" event
// when it's terminated via Android status bar chip.
if (navigator.product === 'ReactNative') {
const mediaStreamTrack = track?.getTrack?.();
if (mediaType === MEDIA_TYPE.SCREENSHARE) {
const onEnded = () => dispatch(toggleScreensharing(false));
mediaStreamTrack.addEventListener('ended', onEnded);
track._onEnded = onEnded;
}
}
if (local) {
// Reset the no data from src notification state when we change the track, as it's context is set
// on a per device basis.
@@ -568,6 +583,16 @@ export function trackRemoved(track: any): {
track.removeAllListeners(JitsiTrackEvents.TRACK_VIDEOTYPE_CHANGED);
track.removeAllListeners(JitsiTrackEvents.NO_DATA_FROM_SOURCE);
// Remove MediaStreamTrack "ended" event.
if (navigator.product === 'ReactNative') {
const mediaStreamTrack = track?.getTrack?.();
if (track._onEnded) {
mediaStreamTrack.removeEventListener('ended', track._onEnded);
delete track._onEnded;
}
}
return {
type: TRACK_REMOVED,
track: {

View File

@@ -33,7 +33,6 @@ import {
isUserInteractionRequiredForUnmute,
setTrackMuted
} from './functions';
import './subscriber';
/**
* Middleware that captures LIB_DID_DISPOSE and LIB_DID_INIT actions and,

View File

@@ -38,6 +38,7 @@ import {
import { ITrack, ITrackOptions } from './types';
import './middleware.any';
import './subscriber.web';
/**
* Middleware that captures LIB_DID_DISPOSE and LIB_DID_INIT actions and,
@@ -142,6 +143,13 @@ MiddlewareRegistry.register(store => next => action => {
if (typeof action.track?.muted !== 'undefined' && participantID && !local) {
logTracksForParticipant(store.getState()['features/base/tracks'], participantID, 'Track updated');
// Notify external API when remote participant mutes/unmutes themselves
const mediaType = isVideoTrack
? (jitsiTrack.getVideoType() === VIDEO_TYPE.DESKTOP ? 'desktop' : 'video')
: 'audio';
APP.API.notifyParticipantMuted(participantID, action.track.muted, mediaType, true);
}
return result;

View File

@@ -1,40 +0,0 @@
import { isEqual, sortBy } from 'lodash-es';
import { MEDIA_TYPE } from '../media/constants';
import { getScreenshareParticipantIds } from '../participants/functions';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { isLocalTrackMuted } from './functions';
/**
* Notifies when the list of currently sharing participants changes.
*/
StateListenerRegistry.register(
/* selector */ state => getScreenshareParticipantIds(state),
/* listener */ (participantIDs, store, previousParticipantIDs) => {
if (typeof APP !== 'object') {
return;
}
if (!isEqual(sortBy(participantIDs), sortBy(previousParticipantIDs))) {
APP.API.notifySharingParticipantsChanged(participantIDs);
}
}
);
/**
* Notifies when the local video mute state changes.
*/
StateListenerRegistry.register(
/* selector */ state => isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO),
/* listener */ (muted, store, previousMuted) => {
if (typeof APP !== 'object') {
return;
}
if (muted !== previousMuted) {
APP.API.notifyVideoMutedStatusChanged(muted);
}
}
);

View File

@@ -0,0 +1,52 @@
import { isEqual, sortBy } from 'lodash-es';
// @ts-expect-error
import VideoLayout from '../../../../modules/UI/videolayout/VideoLayout';
import { getAutoPinSetting } from '../../video-layout/functions.any';
import { MEDIA_TYPE } from '../media/constants';
import { getScreenshareParticipantIds } from '../participants/functions';
import StateListenerRegistry from '../redux/StateListenerRegistry';
import { isLocalTrackMuted } from './functions';
/**
* Notifies when the list of currently sharing participants changes.
*/
StateListenerRegistry.register(
/* selector */ state => getScreenshareParticipantIds(state),
/* listener */ (participantIDs, store, previousParticipantIDs) => {
if (getAutoPinSetting() && participantIDs !== previousParticipantIDs) {
const { participantId } = store.getState()['features/large-video'];
// Check if any new screenshare participants were added
const newParticipants = participantIDs.filter((id: string) => !previousParticipantIDs.includes(id));
// If the current large video participant is a new screensharer, update the display. This is needed when
// the track is created much later after the action for auto-pinning is dispatched. This usually happens in
// very large meetings if the screenshare was already ongoing when the participant joined. The track is
// signaled only after the receiver constraints with SS source id is processed by the bridge but the
// auto-pinning action is dispatched when the participant tile is created as soon as the presence is
// received.
if (participantId && newParticipants.includes(participantId)) {
VideoLayout.updateLargeVideo(participantId, true);
}
}
if (!isEqual(sortBy(participantIDs), sortBy(previousParticipantIDs))) {
APP.API.notifySharingParticipantsChanged(participantIDs);
}
}
);
/**
* Notifies when the local video mute state changes.
*/
StateListenerRegistry.register(
/* selector */ state => isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO),
/* listener */ (muted, store, previousMuted) => {
if (muted !== previousMuted) {
APP.API.notifyVideoMutedStatusChanged(muted);
}
}
);

View File

@@ -22,7 +22,6 @@ export function assignIfDefined(target: Object, source: Object) {
return to;
}
const MATCH_OPERATOR_REGEXP = /[|\\{}()[\]^$+*?.-]/g;
/**
@@ -79,6 +78,21 @@ export function getJitsiMeetGlobalNS() {
return window.JitsiMeetJS.app;
}
/**
* Gets the Electron-specific global namespace.
*
* @returns {Object} The Electron namespace.
*/
export function getElectronGlobalNS() {
const globalNS = getJitsiMeetGlobalNS();
if (!globalNS.electron) {
globalNS.electron = {};
}
return globalNS.electron;
}
/**
* Returns the object that stores the connection times.
*

View File

@@ -78,6 +78,11 @@ interface IProps extends AbstractProps {
*/
_isResizing: boolean;
/**
* The indicator which determines whether the UI is reduced.
*/
_reducedUI: boolean;
/**
* Whether or not to block chat access with a nickname input form.
*/
@@ -227,6 +232,7 @@ const Chat = ({
_focusedTab,
_isResizing,
_messages,
_reducedUI,
_unreadMessagesCount,
_unreadPollsCount,
_unreadFilesCount,
@@ -567,6 +573,10 @@ const Chat = ({
);
}
if (_reducedUI) {
return null;
}
return (
_isOpen ? <div
className = { classes.container }
@@ -623,6 +633,7 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) {
const { isOpen, messages, unreadMessagesCount, unreadFilesCount, width, isResizing } = state['features/chat'];
const { unreadPollsCount } = state['features/polls'];
const _localParticipant = getLocalParticipant(state);
const { reducedUI } = state['features/base/responsive-ui'];
return {
_isModal: window.innerWidth <= SMALL_WIDTH_THRESHOLD,
@@ -633,6 +644,7 @@ function _mapStateToProps(state: IReduxState, _ownProps: any) {
_isFileSharingTabEnabled: isFileSharingEnabled(state),
_focusedTab: getFocusedTab(state),
_messages: messages,
_reducedUI: reducedUI,
_unreadMessagesCount: unreadMessagesCount,
_unreadPollsCount: unreadPollsCount,
_unreadFilesCount: unreadFilesCount,

View File

@@ -4,10 +4,12 @@ import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import Icon from '../../../base/icons/components/Icon';
import { IconSubtitles } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import { groupMessagesBySender } from '../../../base/util/messageGrouping';
import { StartRecordingDialog } from '../../../recording/components/Recording';
import { setRequestingSubtitles } from '../../../subtitles/actions.any';
import LanguageSelector from '../../../subtitles/components/web/LanguageSelector';
import { canStartSubtitles } from '../../../subtitles/functions.any';
@@ -88,6 +90,8 @@ export default function ClosedCaptionsTab() {
const _canStartSubtitles = useSelector(canStartSubtitles);
const [ isButtonPressed, setButtonPressed ] = useState(false);
const subtitlesError = useSelector((state: IReduxState) => state['features/subtitles']._hasError);
const isAsyncTranscriptionEnabled = useSelector((state: IReduxState) =>
state['features/base/conference'].conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription);
const filteredSubtitles = useMemo(() => {
// First, create a map of transcription messages by message ID
@@ -121,14 +125,21 @@ export default function ClosedCaptionsTab() {
groupMessagesBySender(filteredSubtitles), [ filteredSubtitles ]);
const startClosedCaptions = useCallback(() => {
if (isButtonPressed) {
return;
if (isAsyncTranscriptionEnabled) {
dispatch(openDialog('StartRecordingDialog', StartRecordingDialog, {
recordAudioAndVideo: false
}));
} else {
if (isButtonPressed) {
return;
}
dispatch(setRequestingSubtitles(true, false, null));
setButtonPressed(true);
}
dispatch(setRequestingSubtitles(true, false, null));
setButtonPressed(true);
}, [ dispatch, isButtonPressed, setButtonPressed ]);
if (subtitlesError && isButtonPressed) {
}, [ isAsyncTranscriptionEnabled, dispatch, isButtonPressed, openDialog, setButtonPressed ]);
if (subtitlesError && isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
@@ -148,7 +159,7 @@ export default function ClosedCaptionsTab() {
);
}
if (isButtonPressed) {
if (isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}
@@ -165,7 +176,7 @@ export default function ClosedCaptionsTab() {
);
}
if (isButtonPressed) {
if (isButtonPressed && !isAsyncTranscriptionEnabled) {
setButtonPressed(false);
}

View File

@@ -240,6 +240,13 @@ MiddlewareRegistry.register(store => next => action => {
case PARTICIPANT_JOINED:
case PARTICIPANT_LEFT:
case PARTICIPANT_UPDATED: {
if (action.type === PARTICIPANT_LEFT) {
const { privateMessageRecipient } = store.getState()['features/chat'];
if (action.participant?.id === privateMessageRecipient?.id) {
store.dispatch(setPrivateMessageRecipient());
}
}
if (_shouldNotifyPrivateRecipientsChanged(store, action)) {
const result = next(action);
@@ -255,7 +262,7 @@ MiddlewareRegistry.register(store => next => action => {
const conference = getCurrentConference(state);
if (conference) {
// There may be cases when we intend to send a private message but we forget to set the
// There may be cases when we intend to send a private message, but we forgot to set the
// recipient. This logic tries to mitigate this risk.
const shouldSendPrivateMessageTo = _shouldSendPrivateMessageTo(state, action);
@@ -269,29 +276,32 @@ MiddlewareRegistry.register(store => next => action => {
isFromVisitor: shouldSendPrivateMessageTo.isFromVisitor,
displayName: shouldSendPrivateMessageTo.name
}));
// the dialog will take care of sending the message after user confirmation
break;
}
}
// Sending the message if privacy notice doesn't need to be shown.
const { privateMessageRecipient, isLobbyChatActive, lobbyMessageRecipient }
= state['features/chat'];
if (typeof APP !== 'undefined') {
APP.API.notifySendingChatMessage(action.message, Boolean(privateMessageRecipient));
}
if (isLobbyChatActive && lobbyMessageRecipient) {
conference.sendLobbyMessage({
type: LOBBY_CHAT_MESSAGE,
message: action.message
}, lobbyMessageRecipient.id);
_persistSentPrivateMessage(store, lobbyMessageRecipient, action.message, true);
} else if (privateMessageRecipient) {
conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message, 'body', isVisitorChatParticipant(privateMessageRecipient));
_persistSentPrivateMessage(store, privateMessageRecipient, action.message);
} else {
// Sending the message if privacy notice doesn't need to be shown.
const { privateMessageRecipient, isLobbyChatActive, lobbyMessageRecipient }
= state['features/chat'];
if (typeof APP !== 'undefined') {
APP.API.notifySendingChatMessage(action.message, Boolean(privateMessageRecipient));
}
if (isLobbyChatActive && lobbyMessageRecipient) {
conference.sendLobbyMessage({
type: LOBBY_CHAT_MESSAGE,
message: action.message
}, lobbyMessageRecipient.id);
_persistSentPrivateMessage(store, lobbyMessageRecipient, action.message, true);
} else if (privateMessageRecipient) {
conference.sendPrivateTextMessage(privateMessageRecipient.id, action.message, 'body', isVisitorChatParticipant(privateMessageRecipient));
_persistSentPrivateMessage(store, privateMessageRecipient, action.message);
} else {
conference.sendTextMessage(action.message);
}
conference.sendTextMessage(action.message);
}
}
break;

View File

@@ -3,11 +3,10 @@ import React, { useCallback } from 'react';
import {
BackHandler,
NativeModules,
SafeAreaView,
View,
ViewStyle
} from 'react-native';
import { EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context';
import { Edge, EdgeInsets, SafeAreaView, withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect, useDispatch } from 'react-redux';
import { appNavigate } from '../../../app/actions.native';
@@ -436,6 +435,7 @@ class Conference extends AbstractConference<IProps, State> {
</View>
<SafeAreaView
edges = { [ 'left', 'right', 'top' ] }
pointerEvents = 'box-none'
style = {
(_toolboxVisible
@@ -444,6 +444,7 @@ class Conference extends AbstractConference<IProps, State> {
<TitleBar _createOnPress = { this._createOnPress } />
</SafeAreaView>
<SafeAreaView
edges = { [ 'bottom', 'left', 'right', !_toolboxVisible && 'top' ].filter(Boolean) as Edge[] }
pointerEvents = 'box-none'
style = {
(_toolboxVisible

View File

@@ -90,6 +90,11 @@ interface IProps extends AbstractProps, WithTranslation {
*/
_overflowDrawer: boolean;
/**
* The indicator which determines whether the UI is reduced.
*/
_reducedUI: boolean;
/**
* Name for this conference room.
*/
@@ -226,12 +231,45 @@ class Conference extends AbstractConference<IProps, any> {
_layoutClassName,
_notificationsVisible,
_overflowDrawer,
_reducedUI,
_showLobby,
_showPrejoin,
_showVisitorsQueue,
t
} = this.props;
if (_reducedUI) {
return (
<div
id = 'layout_wrapper'
onMouseEnter = { this._onMouseEnter }
onMouseLeave = { this._onMouseLeave }
onMouseMove = { this._onMouseMove }
ref = { this._setBackground }>
<Chat />
<div
className = { _layoutClassName }
id = 'videoconference_page'
onMouseMove = { isMobileBrowser() ? undefined : this._onShowToolbar }>
<ConferenceInfo />
<Notice />
<div
id = 'videospace'
onTouchStart = { this._onVideospaceTouchStart }>
<LargeVideo />
</div>
<span
aria-level = { 1 }
className = 'sr-only'
role = 'heading'>
{ t('toolbar.accessibilityLabel.heading') }
</span>
<Toolbox />
</div>
</div>
);
}
return (
<div
id = 'layout_wrapper'
@@ -418,6 +456,7 @@ class Conference extends AbstractConference<IProps, any> {
function _mapStateToProps(state: IReduxState) {
const { backgroundAlpha, mouseMoveCallbackInterval } = state['features/base/config'];
const { overflowDrawer } = state['features/toolbox'];
const { reducedUI } = state['features/base/responsive-ui'];
return {
...abstractMapStateToProps(state),
@@ -426,6 +465,7 @@ function _mapStateToProps(state: IReduxState) {
_layoutClassName: LAYOUT_CLASSNAMES[getCurrentLayout(state) ?? ''],
_mouseMoveCallbackInterval: mouseMoveCallbackInterval,
_overflowDrawer: overflowDrawer,
_reducedUI: reducedUI,
_roomName: getConferenceNameForTitle(state),
_showLobby: getIsLobbyVisible(state),
_showPrejoin: isPrejoinPageVisible(state),

View File

@@ -34,6 +34,11 @@ interface IProps {
autoHide?: string[];
};
/**
* The indicator which determines whether the UI is reduced.
*/
_reducedUI: boolean;
/**
* Indicates whether the component should be visible or not.
*/
@@ -194,6 +199,12 @@ class ConferenceInfo extends Component<IProps> {
* @returns {ReactElement}
*/
override render() {
const { _reducedUI } = this.props;
if (_reducedUI) {
return null;
}
return (
<div
className = 'details-container'
@@ -217,9 +228,12 @@ class ConferenceInfo extends Component<IProps> {
* }}
*/
function _mapStateToProps(state: IReduxState) {
const { reducedUI } = state['features/base/responsive-ui'];
return {
_conferenceInfo: getConferenceInfo(state),
_reducedUI: reducedUI,
_visible: isToolboxVisible(state),
_conferenceInfo: getConferenceInfo(state)
};
}

View File

@@ -25,9 +25,10 @@ const useStyles = makeStyles()(theme => {
const Notice = () => {
const message = useSelector((state: IReduxState) => state['features/base/config'].noticeMessage);
const { reducedUI } = useSelector((state: IReduxState) => state['features/base/responsive-ui']);
const { classes } = useStyles();
if (!message) {
if (!message || reducedUI) {
return null;
}

View File

@@ -13,6 +13,8 @@ import Tooltip from '../../../base/tooltip/components/Tooltip';
import { getIndicatorsTooltipPosition } from '../../../filmstrip/functions.web';
import { appendSuffix } from '../../functions';
import { getDisplayNameColor } from './styles';
/**
* The type of the React {@code Component} props of {@link DisplayName}.
*/
@@ -49,7 +51,7 @@ const useStyles = makeStyles()(theme => {
return {
displayName: {
...theme.typography.labelBold,
color: theme.palette.text01,
color: getDisplayNameColor(theme),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
@@ -62,7 +64,7 @@ const useStyles = makeStyles()(theme => {
boxShadow: 'none',
padding: 0,
...theme.typography.labelBold,
color: theme.palette.text01
color: getDisplayNameColor(theme)
}
};
});

View File

@@ -121,3 +121,19 @@ export function scaleFontProperty(
return parseFloat(calculatedRemValue.toFixed(3));
}
/**
* Default text color for display name.
*/
export const DISPLAY_NAME_DEFAULT_COLOR = '#FFFFFF';
/**
* Returns the text color for display name from the theme.
*
* @param {Theme} theme - The MUI theme.
* @returns {string} The text color.
*/
export const getDisplayNameColor = (theme: Theme): string =>
theme.palette?.text01 || DISPLAY_NAME_DEFAULT_COLOR;

View File

@@ -1,6 +1,6 @@
import React, { PureComponent } from 'react';
import { FlatList, ViewStyle, ViewToken } from 'react-native';
import { SafeAreaView, withSafeAreaInsets } from 'react-native-safe-area-context';
import { Edge, SafeAreaView, withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
@@ -272,8 +272,8 @@ class Filmstrip extends PureComponent<IProps> {
}
return (
<SafeAreaView // @ts-ignore
edges = { [ bottomEdge && 'bottom', 'left', 'right' ].filter(Boolean) }
<SafeAreaView
edges = { [ bottomEdge && 'bottom', 'left', 'right' ].filter(Boolean) as Edge[] }
style = { filmstripStyle as ViewStyle }>
{
this._separateLocalThumbnail

View File

@@ -2,18 +2,17 @@ import React, { PureComponent } from 'react';
import {
FlatList,
GestureResponderEvent,
SafeAreaView,
TouchableWithoutFeedback,
ViewToken
} from 'react-native';
import { EdgeInsets, withSafeAreaInsets } from 'react-native-safe-area-context';
import { EdgeInsets, SafeAreaView, withSafeAreaInsets } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { getLocalParticipant, getParticipantCountWithFake } from '../../../base/participants/functions';
import { ILocalParticipant } from '../../../base/participants/types';
import { getHideSelfView } from '../../../base/settings/functions.any';
import { setVisibleRemoteParticipants } from '../../actions.web';
import { setVisibleRemoteParticipants } from '../../actions.native';
import Thumbnail from './Thumbnail';
import styles from './styles';

View File

@@ -1,3 +1,5 @@
import { Theme } from '@mui/material/styles';
import { IReduxState } from '../app/types';
import { IStateful } from '../base/app/types';
import { isMobileBrowser } from '../base/environment/utils';
@@ -830,3 +832,13 @@ export function isTopPanelEnabled(state: IReduxState) {
return !filmstrip?.disableTopPanel && participantsCount >= (filmstrip?.minParticipantCountForTopPanel ?? 50);
}
/**
* Returns the thumbnail background color from the theme.
*
* @param {Theme} theme - The MUI theme.
* @returns {string} The background color.
*/
export function getThumbnailBackgroundColor(theme: Theme): string {
return theme.palette.uiBackground;
}

View File

@@ -58,7 +58,7 @@ import { isEnabled as isDropboxEnabled } from '../../dropbox/functions.native';
import { hideNotification, showNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../notifications/constants';
import { RECORDING_SESSION_UPDATED } from '../../recording/actionTypes';
import { RECORDING_METADATA_ID, RECORDING_TYPES } from '../../recording/constants';
import { RECORDING_TYPES } from '../../recording/constants';
import { getActiveSession } from '../../recording/functions';
import { setRequestingSubtitles } from '../../subtitles/actions.any';
import { CUSTOM_BUTTON_PRESSED } from '../../toolbox/actionTypes';
@@ -588,10 +588,7 @@ function _registerForNativeEvents(store: IStore) {
}
if (transcription) {
store.dispatch(setRequestingSubtitles(true, false, null, true));
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: true
});
store.dispatch(setRequestingSubtitles(true, false, null));
}
});
@@ -607,9 +604,6 @@ function _registerForNativeEvents(store: IStore) {
if (transcription) {
store.dispatch(setRequestingSubtitles(false, false, null));
conference.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: false
});
}
if (![ JitsiRecordingConstants.mode.FILE, JitsiRecordingConstants.mode.STREAM ].includes(mode)) {

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SafeAreaView, Text, View, ViewStyle } from 'react-native';
import { Text, View, ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';

View File

@@ -1,8 +1,7 @@
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import React, { useCallback } from 'react';
import { connect, useDispatch } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IReduxState } from '../../../app/types';
import {
getLocalParticipant,
getParticipantById,
@@ -10,14 +9,18 @@ import {
hasRaisedHand,
isParticipantModerator
} from '../../../base/participants/functions';
import { FakeParticipant, IParticipant } from '../../../base/participants/types';
import { FakeParticipant } from '../../../base/participants/types';
import {
isParticipantAudioMuted,
isParticipantVideoMuted
} from '../../../base/tracks/functions.native';
import { showContextMenuDetails, showSharedVideoMenu } from '../../actions.native';
import type { MediaState } from '../../constants';
import { getParticipantAudioMediaState, getParticipantVideoMediaState } from '../../functions';
import {
getParticipantAudioMediaState,
getParticipantVideoMediaState,
participantMatchesSearch
} from '../../functions';
import ParticipantItem from './ParticipantItem';
@@ -59,9 +62,9 @@ interface IProps {
_localVideoOwner: boolean;
/**
* The participant ID.
* Whether or not the participant name matches the search string.
*/
_participantID: string;
_matchesSearch: boolean;
/**
* True if the participant have raised hand.
@@ -74,85 +77,55 @@ interface IProps {
_videoMediaState: MediaState;
/**
* The redux dispatch function.
* The participant ID.
*/
dispatch: IStore['dispatch'];
participantID: string;
/**
* The participant.
* Name of the participant we search for.
*/
participant?: IParticipant;
searchString: string;
}
/**
* Implements the MeetingParticipantItem component.
*/
class MeetingParticipantItem extends PureComponent<IProps> {
/**
* Creates new MeetingParticipantItem instance.
*
* @param {IProps} props - The props of the component.
*/
constructor(props: IProps) {
super(props);
this._onPress = this._onPress.bind(this);
}
/**
* Handles MeetingParticipantItem press events.
*
* @returns {void}
*/
_onPress() {
const {
_fakeParticipant,
_local,
_localVideoOwner,
_participantID,
dispatch
} = this.props;
const MeetingParticipantItem = ({
_audioMediaState,
_disableModeratorIndicator,
_displayName,
_fakeParticipant,
_isModerator,
_local,
_localVideoOwner,
_matchesSearch,
_raisedHand,
_videoMediaState,
participantID
}: IProps) => {
const dispatch = useDispatch();
const onPress = useCallback(() => {
if (_fakeParticipant && _localVideoOwner) {
dispatch(showSharedVideoMenu(_participantID));
dispatch(showSharedVideoMenu(participantID));
} else if (!_fakeParticipant) {
dispatch(showContextMenuDetails(_participantID, _local));
dispatch(showContextMenuDetails(participantID, _local));
} // else no-op
}, [ dispatch ]);
if (!_matchesSearch) {
return null;
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const {
_audioMediaState,
_disableModeratorIndicator,
_displayName,
_isModerator,
_local,
_participantID,
_raisedHand,
_videoMediaState
} = this.props;
return (
<ParticipantItem
audioMediaState = { _audioMediaState }
disableModeratorIndicator = { _disableModeratorIndicator }
displayName = { _displayName }
isModerator = { _isModerator }
local = { _local }
onPress = { this._onPress }
participantID = { _participantID }
raisedHand = { _raisedHand }
videoMediaState = { _videoMediaState } />
);
}
}
return (
<ParticipantItem
audioMediaState = { _audioMediaState }
disableModeratorIndicator = { _disableModeratorIndicator }
displayName = { _displayName }
isModerator = { _isModerator }
local = { _local }
onPress = { onPress }
participantID = { participantID }
raisedHand = { _raisedHand }
videoMediaState = { _videoMediaState } />
);
};
/**
* Maps (parts of) the redux state to the associated props for this component.
@@ -163,8 +136,9 @@ class MeetingParticipantItem extends PureComponent<IProps> {
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState, ownProps: any) {
const { participant } = ownProps;
const { participantID, searchString } = ownProps;
const { ownerId } = state['features/shared-video'];
const participant = getParticipantById(state, participantID);
const localParticipantId = getLocalParticipant(state)?.id;
const _isAudioMuted = isParticipantAudioMuted(participant, state);
const _isVideoMuted = isParticipantVideoMuted(participant, state);
@@ -173,23 +147,24 @@ function mapStateToProps(state: IReduxState, ownProps: any) {
const { disableModeratorIndicator } = state['features/base/config'];
const raisedHand = hasRaisedHand(participant?.local
? participant
: getParticipantById(state, participant?.id)
: getParticipantById(state, participantID)
);
const _matchesSearch = participantMatchesSearch(participant, searchString);
return {
_audioMediaState: audioMediaState,
_disableModeratorIndicator: disableModeratorIndicator,
_displayName: getParticipantDisplayName(state, participant?.id),
_displayName: getParticipantDisplayName(state, participantID),
_fakeParticipant: participant?.fakeParticipant,
_isAudioMuted,
_isModerator: isParticipantModerator(participant),
_local: Boolean(participant?.local),
_localVideoOwner: Boolean(ownerId === localParticipantId),
_participantID: participant?.id,
_matchesSearch,
_raisedHand: raisedHand,
_videoMediaState: videoMediaState
};
}
export default translate(connect(mapStateToProps)(MeetingParticipantItem));
// @ts-ignore
export default connect(mapStateToProps)(MeetingParticipantItem);

View File

@@ -1,16 +1,15 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Text, TextStyle, View } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { connect, useDispatch } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconAddUser } from '../../../base/icons/svg';
import {
addPeopleFeatureControl,
getLocalParticipant,
getParticipantCountWithFake,
getRemoteParticipants,
getParticipantById,
isScreenShareParticipant,
setShareDialogVisiblity
} from '../../../base/participants/functions';
import Button from '../../../base/ui/components/native/Button';
@@ -23,57 +22,70 @@ import {
import { doInvitePeople } from '../../../invite/actions.native';
import { getInviteOthersControl } from '../../../share-room/functions';
import { iAmVisitor } from '../../../visitors/functions';
import { participantMatchesSearch, shouldRenderInviteButton } from '../../functions';
import { getSortedParticipantIds, shouldRenderInviteButton } from '../../functions';
import MeetingParticipantItem from './MeetingParticipantItem';
import styles from './styles';
interface IProps {
currentRoom?: {
jid: string;
name: string;
};
iconColor: string;
isAddPeopleFeatureEnabled?: boolean | undefined;
isShareDialogVisible: boolean;
participantsCount?: number;
showInviteButton?: boolean;
sortedParticipantIds?: Array<string>;
visitorsCount?: number | undefined;
}
const MeetingParticipantList = ({
currentRoom,
iconColor,
isAddPeopleFeatureEnabled,
isShareDialogVisible,
participantsCount,
showInviteButton,
sortedParticipantIds = [],
visitorsCount
}: IProps): any => {
const { t } = useTranslation();
const [ searchString, setSearchString ] = useState('');
const MeetingParticipantList = () => {
const currentRoomId = useSelector(getCurrentRoomId);
const currentRoom = useSelector(getBreakoutRooms)[currentRoomId];
const dispatch = useDispatch();
const inviteOthersControl = useSelector(getInviteOthersControl);
const isAddPeopleFeatureEnabled = useSelector(addPeopleFeatureControl);
const keyExtractor
= useCallback((e: undefined, i: number) => i.toString(), []);
const localParticipant = useSelector(getLocalParticipant);
const _iAmVisitor = useSelector(iAmVisitor);
const keyExtractor = useCallback((e: undefined, i: number) => i.toString(), []);
const onInvite = useCallback(() => {
setShareDialogVisiblity(isAddPeopleFeatureEnabled, dispatch);
dispatch(doInvitePeople());
}, [ dispatch ]);
const [ searchString, setSearchString ] = useState('');
const onSearchStringChange = useCallback((text: string) =>
setSearchString(text), []);
const participantsCount = useSelector(getParticipantCountWithFake);
const remoteParticipants = useSelector(getRemoteParticipants);
const renderParticipant = ({ item/* , index, separators */ }: any) => {
const participant = item === localParticipant?.id
? localParticipant : remoteParticipants.get(item);
if (participantMatchesSearch(participant, searchString)) {
return (
<MeetingParticipantItem
key = { item }
participant = { participant } />
);
}
return null;
};
const showInviteButton = useSelector(shouldRenderInviteButton);
const sortedRemoteParticipants = useSelector(
(state: IReduxState) => state['features/filmstrip'].remoteParticipants);
const { t } = useTranslation();
const title = currentRoom?.name
? `${currentRoom.name} (${participantsCount})`
: t('participantsPane.headings.participantsList',
{ count: participantsCount });
const { color, shareDialogVisible } = inviteOthersControl;
const visitorsLabelText = visitorsCount && visitorsCount > 0
? t('participantsPane.headings.visitors', { count: visitorsCount })
: undefined;
const renderParticipant = ({ item }: any) => (
<MeetingParticipantItem
key = { item }
participantID = { item }
searchString = { searchString } />
);
return (
<View style = { styles.meetingListContainer }>
<Text style = { styles.visitorsLabel as TextStyle }>
{ visitorsLabelText }
</Text>
<Text
style = { styles.meetingListDescription as TextStyle }>
{ title }
@@ -82,12 +94,12 @@ const MeetingParticipantList = () => {
showInviteButton
&& <Button
accessibilityLabel = 'participantsPane.actions.invite'
disabled = { shareDialogVisible }
disabled = { isShareDialogVisible }
// eslint-disable-next-line react/jsx-no-bind, no-confusing-arrow
icon = { () => (
<Icon
color = { color }
color = { iconColor }
size = { 20 }
src = { IconAddUser } />
) }
@@ -105,10 +117,7 @@ const MeetingParticipantList = () => {
placeholder = { t('participantsPane.search') }
value = { searchString } />
<FlatList
data = { _iAmVisitor
? [ ...sortedRemoteParticipants ]
: [ localParticipant?.id, ...sortedRemoteParticipants ] as Array<any>
}
data = { sortedParticipantIds as Array<any> }
keyExtractor = { keyExtractor }
/* eslint-disable react/jsx-no-bind */
@@ -118,4 +127,48 @@ const MeetingParticipantList = () => {
);
};
export default MeetingParticipantList;
/**
* Maps (parts of) the redux state to the associated props for this component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
let sortedParticipantIds: any = getSortedParticipantIds(state);
const _iAmVisitor = iAmVisitor(state);
sortedParticipantIds = sortedParticipantIds.filter((id: any) => {
const participant = getParticipantById(state, id);
if (_iAmVisitor && participant?.local) {
return false;
}
return !isScreenShareParticipant(participant);
});
const currentRoomId = getCurrentRoomId(state);
const currentRoom = getBreakoutRooms(state)[currentRoomId];
const inviteOthersControl = getInviteOthersControl(state);
const { color, shareDialogVisible } = inviteOthersControl;
const isAddPeopleFeatureEnabled = addPeopleFeatureControl(state);
const participantsCount = sortedParticipantIds.length;
const showInviteButton = shouldRenderInviteButton(state);
const visitorsCount = state['features/visitors']?.count || 0;
return {
currentRoom,
iconColor: color,
inviteOthersControl,
isAddPeopleFeatureEnabled,
isShareDialogVisible: shareDialogVisible,
participantsCount,
showInviteButton,
sortedParticipantIds,
visitorsCount
};
}
export default connect(_mapStateToProps)(MeetingParticipantList);

View File

@@ -12,11 +12,6 @@ import VisitorsList from './VisitorsList';
import styles from './styles';
/**
* Participants pane.
*
* @returns {React$Element<any>}
*/
const ParticipantsPane = () => {
const isLocalModerator = useSelector(isLocalParticipantModerator);
const keyExtractor

View File

@@ -9,7 +9,7 @@ const participantListDescription = {
fontSize: 15,
fontWeight: 'bold',
marginLeft: BaseTheme.spacing[2],
paddingVertical: BaseTheme.spacing[2],
marginVertical: BaseTheme.spacing[2],
position: 'relative',
width: '70%'
};
@@ -275,13 +275,11 @@ export default {
},
inputContainer: {
marginLeft: BaseTheme.spacing[3],
marginRight: BaseTheme.spacing[3],
marginBottom: BaseTheme.spacing[4]
marginHorizontal: BaseTheme.spacing[3],
marginBottom: BaseTheme.spacing[3]
},
centerInput: {
paddingRight: BaseTheme.spacing[3],
textAlign: 'center'
},

View File

@@ -13,7 +13,7 @@ import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../b
import { isButtonEnabled, showOverflowDrawer } from '../../../toolbox/functions.web';
import { iAmVisitor } from '../../../visitors/functions';
import { getSortedParticipantIds, isCurrentRoomRenamable, shouldRenderInviteButton } from '../../functions';
import { useParticipantDrawer } from '../../hooks';
import { useParticipantDrawer } from '../../hooks.web';
import RenameButton from '../breakout-rooms/components/web/RenameButton';
import { InviteButton } from './InviteButton';

View File

@@ -0,0 +1,4 @@
/**
* Action type to set Picture-in-Picture active state.
*/
export const SET_PIP_ACTIVE = 'SET_PIP_ACTIVE';

View File

@@ -0,0 +1,193 @@
import { IStore } from '../app/types';
import { MEDIA_TYPE } from '../base/media/constants';
import { isLocalTrackMuted } from '../base/tracks/functions.any';
import { handleToggleVideoMuted } from '../toolbox/actions.any';
import { muteLocal } from '../video-menu/actions.any';
import { SET_PIP_ACTIVE } from './actionTypes';
import {
cleanupMediaSessionHandlers,
enterPiP,
setupMediaSessionHandlers,
shouldShowPiP
} from './functions';
import logger from './logger';
/**
* Action to set Picture-in-Picture active state.
*
* @param {boolean} isPiPActive - Whether PiP is active.
* @returns {{
* type: SET_PIP_ACTIVE,
* isPiPActive: boolean
* }}
*/
export function setPiPActive(isPiPActive: boolean) {
return {
type: SET_PIP_ACTIVE,
isPiPActive
};
}
/**
* Toggles audio mute from PiP MediaSession controls.
* Uses exact same logic as toolbar audio button including GUM pending state.
*
* @returns {Function}
*/
export function toggleAudioFromPiP() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const audioMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
// Use the exact same action as toolbar button.
dispatch(muteLocal(!audioMuted, MEDIA_TYPE.AUDIO));
};
}
/**
* Toggles video mute from PiP MediaSession controls.
* Uses exact same logic as toolbar video button including GUM pending state.
*
* @returns {Function}
*/
export function toggleVideoFromPiP() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const videoMuted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO);
// Use the exact same action as toolbar button (showUI=true, ensureTrack=true).
dispatch(handleToggleVideoMuted(!videoMuted, true, true));
};
}
/**
* Action to exit Picture-in-Picture mode.
*
* @returns {Function}
*/
export function exitPiP() {
return (dispatch: IStore['dispatch']) => {
if (document.pictureInPictureElement) {
document.exitPictureInPicture()
.then(() => {
logger.debug('Exited Picture-in-Picture mode');
})
.catch((err: Error) => {
logger.error(`Error while exiting PiP: ${err.message}`);
});
}
dispatch(setPiPActive(false));
cleanupMediaSessionHandlers();
};
}
/**
* Action to handle window blur or tab switch.
* Enters PiP mode if not already active.
*
* @param {HTMLVideoElement} videoElement - The video element we will use for PiP.
* @returns {Function}
*/
export function handleWindowBlur(videoElement: HTMLVideoElement) {
return (_dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const isPiPActive = state['features/pip']?.isPiPActive;
if (!isPiPActive) {
enterPiP(videoElement);
}
};
}
/**
* Action to handle window focus.
* Exits PiP mode if currently active (matches old AOT behavior).
*
* @returns {Function}
*/
export function handleWindowFocus() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const isPiPActive = state['features/pip']?.isPiPActive;
if (isPiPActive) {
dispatch(exitPiP());
}
};
}
/**
* Action to handle the browser's leavepictureinpicture event.
* Updates state and cleans up MediaSession handlers.
*
* @returns {Function}
*/
export function handlePiPLeaveEvent() {
return (dispatch: IStore['dispatch']) => {
logger.log('Left Picture-in-Picture mode');
dispatch(setPiPActive(false));
cleanupMediaSessionHandlers();
APP.API.notifyPictureInPictureLeft();
};
}
/**
* Action to handle the browser's enterpictureinpicture event.
* Updates state and sets up MediaSession handlers.
*
* @returns {Function}
*/
export function handlePipEnterEvent() {
return (dispatch: IStore['dispatch']) => {
logger.log('Entered Picture-in-Picture mode');
dispatch(setPiPActive(true));
setupMediaSessionHandlers(dispatch);
APP.API.notifyPictureInPictureEntered();
};
}
/**
* Shows Picture-in-Picture window.
* Called from external API when iframe becomes not visible (IntersectionObserver).
*
* @returns {Function}
*/
export function showPiP() {
return (_dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const isPiPActive = state['features/pip']?.isPiPActive;
if (!shouldShowPiP(state)) {
return;
}
if (!isPiPActive) {
const videoElement = document.getElementById('pipVideo') as HTMLVideoElement;
if (videoElement) {
enterPiP(videoElement);
}
}
};
}
/**
* Hides Picture-in-Picture window.
* Called from external API when iframe becomes visible.
*
* @returns {Function}
*/
export function hidePiP() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const isPiPActive = state['features/pip']?.isPiPActive;
if (isPiPActive) {
dispatch(exitPiP());
}
};
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { shouldShowPiP } from '../functions';
import PiPVideoElement from './PiPVideoElement';
/**
* Wrapper component that conditionally renders PiPVideoElement.
* Prevents mounting when PiP is disabled or on prejoin without showOnPrejoin flag.
*
* @returns {React.ReactElement | null}
*/
function PiP() {
const showPiP = useSelector(shouldShowPiP);
if (!showPiP) {
return null;
}
return <PiPVideoElement />;
}
export default PiP;

View File

@@ -0,0 +1,233 @@
import React, { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../app/types';
import { getAvatarFont, getAvatarInitialsColor } from '../../base/avatar/components/web/styles';
import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants/functions';
import { isTrackStreamingStatusActive } from '../../connection-indicator/functions';
import { getDisplayNameColor } from '../../display-name/components/web/styles';
import { getThumbnailBackgroundColor } from '../../filmstrip/functions.web';
import { getLargeVideoParticipant } from '../../large-video/functions';
import { isPrejoinPageVisible } from '../../prejoin/functions.any';
import { handlePiPLeaveEvent, handlePipEnterEvent, handleWindowBlur, handleWindowFocus } from '../actions';
import { getPiPVideoTrack } from '../functions';
import { useCanvasAvatar } from '../hooks';
import logger from '../logger';
const useStyles = makeStyles()(() => {
return {
hiddenVideo: {
position: 'absolute' as const,
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none' as const,
left: '-9999px',
top: '-9999px'
}
};
});
/**
* Component that renders a hidden video element for Picture-in-Picture.
* Automatically switches between real video track and canvas-based avatar
* depending on video availability.
*
* @returns {JSX.Element} The hidden video element.
*/
const PiPVideoElement: React.FC = () => {
const { classes, theme } = useStyles();
const videoRef = useRef<HTMLVideoElement>(null);
const previousTrackRef = useRef<any>(null);
// Redux selectors.
const isOnPrejoin = useSelector(isPrejoinPageVisible);
const localParticipant = useSelector(getLocalParticipant);
const largeVideoParticipant = useSelector(getLargeVideoParticipant);
// Use local participant during prejoin, otherwise large video participant.
const participant = isOnPrejoin ? localParticipant : largeVideoParticipant;
// Get appropriate video track based on prejoin state.
const videoTrack = useSelector((state: IReduxState) =>
getPiPVideoTrack(state, participant)
);
const displayName = useSelector((state: IReduxState) =>
participant?.id
? getParticipantDisplayName(state, participant.id)
: ''
);
const customAvatarBackgrounds = useSelector((state: IReduxState) =>
state['features/dynamic-branding']?.avatarBackgrounds || []
);
const dispatch: IStore['dispatch'] = useDispatch();
const avatarFont = getAvatarFont(theme);
const fontFamily = (avatarFont as any).fontFamily ?? 'Inter, sans-serif';
const initialsColor = getAvatarInitialsColor(theme);
const displayNameColor = getDisplayNameColor(theme);
const { canvasStreamRef } = useCanvasAvatar({
participant,
displayName,
customAvatarBackgrounds,
backgroundColor: getThumbnailBackgroundColor(theme),
fontFamily,
initialsColor,
displayNameColor
});
// Determine if we should show avatar instead of video.
const shouldShowAvatar = !videoTrack
|| videoTrack.muted
|| (!videoTrack.local && !isTrackStreamingStatusActive(videoTrack));
/**
* Effect: Handle switching between real video track and canvas avatar stream.
*/
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) {
return;
}
const previousTrack = previousTrackRef.current;
// Detach previous track.
if (previousTrack?.jitsiTrack) {
try {
previousTrack.jitsiTrack.detach(videoElement);
} catch (error) {
logger.error('Error detaching previous track:', error);
}
}
if (shouldShowAvatar) {
// Use canvas stream for avatar.
// Access ref inside effect - stream is created in useCanvasAvatar's effect.
const canvasStream = canvasStreamRef.current;
// Only set srcObject if it's different to avoid interrupting playback.
if (canvasStream && videoElement.srcObject !== canvasStream) {
videoElement.srcObject = canvasStream;
}
} else if (videoTrack?.jitsiTrack) {
// Attach real video track.
videoTrack.jitsiTrack.attach(videoElement)
.catch((error: Error) => {
logger.error('Error attaching video track:', error);
});
}
previousTrackRef.current = videoTrack;
// Cleanup on unmount or track change.
return () => {
if (videoTrack?.jitsiTrack && videoElement) {
try {
videoTrack.jitsiTrack.detach(videoElement);
} catch (error) {
logger.error('Error during cleanup:', error);
}
}
};
}, [ videoTrack, shouldShowAvatar ]);
/**
* Effect: Window blur/focus and visibility change listeners.
* Enters PiP on blur, exits on focus (matches old AOT behavior).
*/
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) {
return;
}
const onWindowBlur = () => dispatch(handleWindowBlur(videoElement));
const onWindowFocus = () => {
// In the use case where the PiP is closed by the 'X' or 'back to main window' buttons, this handler is
// called before the leavepictureinpicture handler. From there we call document.exitPictureInPicture()
// which seems to put Chrome into a weird state - document.exitPictureInPicture() never resolves, the
// leavepictureinpicture is never triggered and it is not possible to display PiP again.
// This is probably a browser bug. To workaround it we have the 100ms timeout here. This way this event
// is triggered after the leavepictureinpicture event and everything seems to work well.
setTimeout(() => {
dispatch(handleWindowFocus());
}, 100);
};
const onVisibilityChange = () => {
if (document.hidden) {
onWindowBlur();
}
};
window.addEventListener('blur', onWindowBlur);
window.addEventListener('focus', onWindowFocus);
document.addEventListener('visibilitychange', onVisibilityChange);
// Check if window is already blurred on mount (handles PiP enable while app is in background).
// Wait for video to be ready before attempting PiP (canvas stream may not be attached yet).
const checkFocusAndEnterPiP = () => {
if (!document.hasFocus()) {
onWindowBlur();
}
};
if (videoElement.readyState >= 1) {
// Video already has metadata loaded (e.g., real video track was already attached).
checkFocusAndEnterPiP();
} else {
// Wait for video source to be ready (e.g., canvas stream being created).
videoElement.addEventListener('loadedmetadata', checkFocusAndEnterPiP, { once: true });
}
return () => {
window.removeEventListener('blur', onWindowBlur);
window.removeEventListener('focus', onWindowFocus);
document.removeEventListener('visibilitychange', onVisibilityChange);
videoElement.removeEventListener('loadedmetadata', checkFocusAndEnterPiP);
};
}, [ dispatch ]);
/**
* Effect: PiP enter/leave event listeners.
* Updates Redux state when browser PiP events occur.
*/
useEffect(() => {
const videoElement = videoRef.current;
if (!videoElement) {
return;
}
const onEnterPiP = () => {
dispatch(handlePipEnterEvent());
};
const onLeavePiP = () => {
dispatch(handlePiPLeaveEvent());
};
videoElement.addEventListener('enterpictureinpicture', onEnterPiP);
videoElement.addEventListener('leavepictureinpicture', onLeavePiP);
return () => {
videoElement.removeEventListener('enterpictureinpicture', onEnterPiP);
videoElement.removeEventListener('leavepictureinpicture', onLeavePiP);
};
}, [ dispatch ]);
return (
<video
autoPlay = { true }
className = { classes.hiddenVideo }
id = 'pipVideo'
muted = { true }
playsInline = { true }
ref = { videoRef } />
);
};
export default PiPVideoElement;

View File

@@ -0,0 +1,31 @@
/**
* Shared utilities for PiP feature used by external_api.js.
*
* IMPORTANT: Keep this file minimal with no heavy dependencies.
* It's bundled into external_api.min.js and we want to keep that bundle slim.
* Only import lightweight modules here.
*/
/**
* Checks if current environment is Electron.
* Inline check to avoid importing BrowserDetection and its ua-parser dependency.
*
* @returns {boolean} - True if running in Electron.
*/
function isElectron(): boolean {
return navigator.userAgent.includes('Electron');
}
/**
* Checks if PiP is enabled based on config and environment.
*
* @param {Object} pipConfig - The pip config object.
* @returns {boolean} - True if PiP is enabled.
*/
export function isPiPEnabled(pipConfig?: { disabled?: boolean; }): boolean {
if (pipConfig?.disabled) {
return false;
}
return isElectron();
}

View File

@@ -0,0 +1,461 @@
import { IReduxState, IStore } from '../app/types';
import { AVATAR_DEFAULT_BACKGROUND_COLOR } from '../base/avatar/components/web/styles';
import { getAvatarColor, getInitials } from '../base/avatar/functions';
import { leaveConference } from '../base/conference/actions';
import { browser } from '../base/lib-jitsi-meet';
import { IParticipant } from '../base/participants/types';
import { getLocalVideoTrack } from '../base/tracks/functions.any';
import { getVideoTrackByParticipant } from '../base/tracks/functions.web';
import { isPrejoinPageVisible } from '../prejoin/functions.any';
import { toggleAudioFromPiP, toggleVideoFromPiP } from './actions';
import { isPiPEnabled } from './external-api.shared';
import logger from './logger';
import { IMediaSessionState } from './types';
/**
* Gets the appropriate video track for PiP based on prejoin state.
* During prejoin, returns local video track. In conference, returns large video participant's track.
*
* @param {IReduxState} state - Redux state.
* @param {IParticipant | undefined} participant - Participant to get track for.
* @returns {ITrack | undefined} The video track or undefined.
*/
export function getPiPVideoTrack(state: IReduxState, participant: IParticipant | undefined) {
const isOnPrejoin = isPrejoinPageVisible(state);
return isOnPrejoin
? getLocalVideoTrack(state['features/base/tracks'])
: getVideoTrackByParticipant(state, participant);
}
/**
* Determines if PiP should be shown based on config and current app state.
* Checks if PiP is enabled and handles prejoin page visibility.
*
* @param {IReduxState} state - Redux state.
* @returns {boolean} Whether PiP should be shown.
*/
export function shouldShowPiP(state: IReduxState): boolean {
const pipConfig = state['features/base/config'].pip;
// Check if PiP is enabled at all.
if (!isPiPEnabled(pipConfig)) {
return false;
}
// Check prejoin state.
const isOnPrejoin = isPrejoinPageVisible(state);
const showOnPrejoin = pipConfig?.showOnPrejoin ?? false;
// Don't show PiP on prejoin unless explicitly enabled.
if (isOnPrejoin && !showOnPrejoin) {
return false;
}
return true;
}
/**
* Draws an image-based avatar as a circular clipped image on canvas.
*
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
* @param {string} imageUrl - URL of the avatar image.
* @param {boolean | undefined} useCORS - Whether to use CORS for image loading.
* @param {number} centerX - X coordinate of avatar center.
* @param {number} centerY - Y coordinate of avatar center.
* @param {number} radius - Radius of the avatar circle.
* @returns {Promise<void>}
*/
export async function drawImageAvatar(
ctx: CanvasRenderingContext2D,
imageUrl: string,
useCORS: boolean | undefined,
centerX: number,
centerY: number,
radius: number
): Promise<void> {
const img = new Image();
if (useCORS) {
img.crossOrigin = 'anonymous';
}
img.src = imageUrl;
try {
await img.decode();
ctx.save();
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.clip();
const size = radius * 2;
ctx.drawImage(img, centerX - radius, centerY - radius, size, size);
ctx.restore();
} catch (error) {
logger.error('Failed to draw image avatar', error);
throw new Error('Image load failed');
}
}
/**
* Draws an initials-based avatar with a colored background on canvas.
*
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
* @param {string} name - Participant's display name.
* @param {Array<string>} customAvatarBackgrounds - Custom avatar background colors.
* @param {number} centerX - X coordinate of avatar center.
* @param {number} centerY - Y coordinate of avatar center.
* @param {number} radius - Radius of the avatar circle.
* @param {string} fontFamily - Font family to use for initials.
* @param {string} textColor - Color for the initials text.
* @returns {void}
*/
export function drawInitialsAvatar(
ctx: CanvasRenderingContext2D,
name: string,
customAvatarBackgrounds: Array<string>,
centerX: number,
centerY: number,
radius: number,
fontFamily: string,
textColor: string
) {
const initials = getInitials(name);
const color = getAvatarColor(name, customAvatarBackgrounds);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = textColor;
ctx.font = `bold 80px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(initials, centerX, centerY);
}
/**
* Draws the default user icon when no avatar is available.
*
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
* @param {HTMLImageElement | null} defaultIcon - Preloaded default icon image.
* @param {number} centerX - X coordinate of icon center.
* @param {number} centerY - Y coordinate of icon center.
* @param {number} radius - Radius of the icon circle.
* @returns {void}
*/
export function drawDefaultIcon(
ctx: CanvasRenderingContext2D,
defaultIcon: HTMLImageElement | null,
centerX: number,
centerY: number,
radius: number
) {
ctx.fillStyle = AVATAR_DEFAULT_BACKGROUND_COLOR;
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.fill();
if (defaultIcon) {
const iconSize = radius;
const x = centerX - iconSize / 2;
const y = centerY - iconSize / 2;
ctx.drawImage(defaultIcon, x, y, iconSize, iconSize);
}
}
/**
* Maximum character limit for display name before truncation.
*/
const DISPLAY_NAME_MAX_CHARS = 25;
/**
* Draws the participant's display name below the avatar.
* Truncates long names with ellipsis using a simple character limit.
*
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
* @param {string} displayName - Participant's display name.
* @param {number} centerX - X coordinate of text center.
* @param {number} y - Y coordinate of text top.
* @param {string} fontFamily - Font family to use for display name.
* @param {string} textColor - Color for the display name text.
* @returns {void}
*/
export function drawDisplayName(
ctx: CanvasRenderingContext2D,
displayName: string,
centerX: number,
y: number,
fontFamily: string,
textColor: string
) {
const truncated = displayName.length > DISPLAY_NAME_MAX_CHARS
? `${displayName.slice(0, DISPLAY_NAME_MAX_CHARS)}...`
: displayName;
ctx.fillStyle = textColor;
ctx.font = `24px ${fontFamily}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(truncated, centerX, y);
}
/**
* Renders a complete avatar (image, initials, or default icon) with display name on canvas.
*
* @param {HTMLCanvasElement} canvas - The canvas element.
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context.
* @param {IParticipant | undefined} participant - The participant to render.
* @param {string} displayName - The display name to show.
* @param {Array<string>} customAvatarBackgrounds - Custom avatar background colors.
* @param {HTMLImageElement | null} defaultIcon - Preloaded default icon image.
* @param {string} backgroundColor - Background color for the canvas.
* @param {string} fontFamily - Font family to use for text rendering.
* @param {string} initialsColor - Color for avatar initials text.
* @param {string} displayNameColor - Color for display name text.
* @returns {Promise<void>}
*/
export async function renderAvatarOnCanvas(
canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D,
participant: IParticipant | undefined,
displayName: string,
customAvatarBackgrounds: Array<string>,
defaultIcon: HTMLImageElement | null,
backgroundColor: string,
fontFamily: string,
initialsColor: string,
displayNameColor: string
): Promise<void> {
const { width, height } = canvas;
const centerX = width / 2;
const centerY = height / 2;
const avatarRadius = 100;
const spacing = 20;
const textY = centerY + avatarRadius + spacing;
// Clear and fill background.
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, width, height);
let avatarRendered = false;
if (participant?.loadableAvatarUrl) {
try {
await drawImageAvatar(
ctx,
participant.loadableAvatarUrl,
participant.loadableAvatarUrlUseCORS,
centerX,
centerY,
avatarRadius
);
avatarRendered = true;
} catch (error) {
logger.warn('Failed to load image avatar, falling back.', error);
}
}
if (!avatarRendered) {
if (participant?.name) {
drawInitialsAvatar(
ctx, participant.name, customAvatarBackgrounds, centerX, centerY, avatarRadius, fontFamily, initialsColor
);
} else {
drawDefaultIcon(ctx, defaultIcon, centerX, centerY, avatarRadius);
}
}
drawDisplayName(ctx, displayName, centerX, textY, fontFamily, displayNameColor);
}
/**
* Requests picture-in-picture mode for the pip video element.
*
* NOTE: Called by Electron main process with userGesture: true.
*
* @returns {void}
*/
export function requestPictureInPicture() {
const video = document.getElementById('pipVideo') as HTMLVideoElement;
if (!video) {
logger.error('PiP video element (#pipVideo) not found');
return;
}
if (document.pictureInPictureElement) {
logger.warn('Already in PiP mode');
return;
}
// Check if video metadata is loaded.
// readyState >= 1 (HAVE_METADATA) means video dimensions are available.
if (video.readyState < 1) {
logger.warn('Video metadata not loaded yet, waiting...');
// Wait for metadata to load before requesting PiP.
video.addEventListener('loadedmetadata', () => {
// @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
video.requestPictureInPicture().catch((err: Error) => {
logger.error(`Error while requesting PiP after metadata loaded: ${err.message}`);
});
}, { once: true });
return;
}
// @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
video.requestPictureInPicture().catch((err: Error) => {
logger.error(`Error while requesting PiP: ${err.message}`);
});
}
/**
* Action to enter Picture-in-Picture mode.
* Handles both browser and Electron environments.
*
* @param {HTMLVideoElement} videoElement - The video element to call requestPictureInPicuture on.
* @returns {void}
*/
export function enterPiP(videoElement: HTMLVideoElement | undefined | null) {
if (!videoElement) {
logger.error('PiP video element not found');
return;
}
// Check if PiP is supported.
if (!('pictureInPictureEnabled' in document)) {
logger.error('Picture-in-Picture is not supported in this browser');
return;
}
if (document.pictureInPictureEnabled === false) {
logger.error('Picture-in-Picture is disabled');
return;
}
try {
// In Electron, use postMessage to request PiP from main process.
// This bypasses the transient activation requirement by executing
// requestPictureInPicture with userGesture: true in the main process.
if (browser.isElectron()) {
logger.log('Electron detected, sending postMessage to request PiP');
APP.API.notifyPictureInPictureRequested();
// State will be updated by enterpictureinpicture event.
return;
}
// TODO: Enable PiP for browsers:
// In browsers, we should directly call requestPictureInPicture.
// @ts-ignore - requestPictureInPicture is not yet in all TypeScript definitions.
// requestPictureInPicture();
} catch (error) {
logger.error('Error entering Picture-in-Picture:', error);
}
}
/**
* Sets up MediaSession API action handlers for controlling the conference.
* Handlers dispatch actions that query fresh Redux state, avoiding stale closures.
*
* @param {Function} dispatch - Redux dispatch function.
* @returns {void}
*/
export function setupMediaSessionHandlers(dispatch: IStore['dispatch']) {
// @ts-ignore - MediaSession API is not fully typed in all environments.
if ('mediaSession' in navigator && navigator.mediaSession?.setActionHandler) {
try {
// Set up audio mute toggle handler.
// Dispatch action that will query current state and toggle.
// @ts-ignore - togglemicrophone is a newer MediaSession action.
navigator.mediaSession.setActionHandler('togglemicrophone', () => {
dispatch(toggleAudioFromPiP());
});
// Set up video mute toggle handler.
// Dispatch action that will query current state and toggle.
// @ts-ignore - togglecamera is a newer MediaSession action.
navigator.mediaSession.setActionHandler('togglecamera', () => {
dispatch(toggleVideoFromPiP());
});
// Set up hangup handler.
// @ts-ignore - hangup is a newer MediaSession action.
navigator.mediaSession.setActionHandler('hangup', () => {
dispatch(leaveConference());
});
logger.log('MediaSession API handlers registered for PiP controls');
} catch (error) {
logger.warn('Some MediaSession actions not supported:', error);
}
} else {
logger.warn('MediaSession API not supported in this browser');
}
}
/**
* Updates the MediaSession API microphone and camera active state.
* This ensures the PiP controls show the correct mute/unmute state.
*
* @param {IMediaSessionState} state - The current media session state.
* @returns {void}
*/
export function updateMediaSessionState(state: IMediaSessionState) {
if ('mediaSession' in navigator) {
try {
// @ts-ignore - setMicrophoneActive is a newer MediaSession method.
if (navigator.mediaSession.setMicrophoneActive) {
// @ts-ignore
navigator.mediaSession.setMicrophoneActive(state.microphoneActive);
}
// @ts-ignore - setCameraActive is a newer MediaSession method.
if (navigator.mediaSession.setCameraActive) {
// @ts-ignore
navigator.mediaSession.setCameraActive(state.cameraActive);
}
logger.log('MediaSession state updated:', state);
} catch (error) {
logger.warn('Error updating MediaSession state:', error);
}
}
}
/**
* Cleans up MediaSession API action handlers.
*
* @returns {void}
*/
export function cleanupMediaSessionHandlers() {
if ('mediaSession' in navigator) {
try {
// Note: Setting handlers to null is commented out as it may cause issues
// in some browsers. The handlers will be overwritten when entering PiP again.
// @ts-ignore - togglemicrophone is a newer MediaSession action.
navigator.mediaSession.setActionHandler('togglemicrophone', null);
// @ts-ignore - togglecamera is a newer MediaSession action.
navigator.mediaSession.setActionHandler('togglecamera', null);
// @ts-ignore - hangup is a newer MediaSession action.
navigator.mediaSession.setActionHandler('hangup', null);
logger.log('MediaSession API handlers cleaned up');
} catch (error) {
logger.error('Error cleaning up MediaSession handlers:', error);
}
}
}
// Re-export from shared file for external use.
export { isPiPEnabled };

183
react/features/pip/hooks.ts Normal file
View File

@@ -0,0 +1,183 @@
import React, { useEffect, useRef } from 'react';
import IconUserSVG from '../base/icons/svg/user.svg?raw';
import { IParticipant } from '../base/participants/types';
import { TILE_ASPECT_RATIO } from '../filmstrip/constants';
import { renderAvatarOnCanvas } from './functions';
import logger from './logger';
/**
* Canvas dimensions for PiP avatar rendering.
*/
const CANVAS_WIDTH = 640;
const CANVAS_HEIGHT = Math.floor(CANVAS_WIDTH / TILE_ASPECT_RATIO);
/**
* Frame rate 0 means capture on-demand when canvas changes.
* We manually request frames after drawing to ensure capture.
*/
const CANVAS_FRAME_RATE = 0;
/**
* Options for the useCanvasAvatar hook.
*/
interface IUseCanvasAvatarOptions {
backgroundColor: string;
customAvatarBackgrounds: string[];
displayName: string;
displayNameColor: string;
fontFamily: string;
initialsColor: string;
participant: IParticipant | undefined;
}
/**
* Result returned by the useCanvasAvatar hook.
* Returns a ref object so consumers can access .current inside effects
* (the stream is created in an effect and won't be available at render time).
*/
interface IUseCanvasAvatarResult {
canvasStreamRef: React.MutableRefObject<MediaStream | null>;
}
/**
* Internal refs managed by the hook.
*/
interface ICanvasRefs {
canvas: HTMLCanvasElement | null;
defaultIcon: HTMLImageElement | null;
}
/**
* Loads and prepares the default user icon SVG as an Image element.
*
* @returns {HTMLImageElement} The prepared image element.
*/
function createDefaultIconImage(): HTMLImageElement {
let svgText = IconUserSVG;
if (!svgText.includes('fill=')) {
svgText = svgText.replace('<svg', '<svg fill="#FFFFFF"');
}
const dataUrl = `data:image/svg+xml,${encodeURIComponent(svgText)
.replace(/'/g, '%27')
.replace(/"/g, '%22')}`;
const img = new Image();
img.src = dataUrl;
return img;
}
/**
* Custom hook that manages canvas-based avatar rendering for Picture-in-Picture.
* Creates and maintains a canvas element with a MediaStream that can be used
* as a video source when the participant's video is unavailable.
*
* @param {IUseCanvasAvatarOptions} options - The hook options.
* @returns {IUseCanvasAvatarResult} The canvas stream for use as video source.
*/
export function useCanvasAvatar(options: IUseCanvasAvatarOptions): IUseCanvasAvatarResult {
const {
participant,
displayName,
customAvatarBackgrounds,
backgroundColor,
fontFamily,
initialsColor,
displayNameColor
} = options;
const refs = useRef<ICanvasRefs>({
canvas: null,
defaultIcon: null
});
// Separate ref for the stream to return to consumers.
// This allows consumers to access .current inside their effects.
//
// NOTE: If we ever need to recreate the stream (e.g., different canvas size),
// consumers' effects won't automatically re-run since refs don't trigger re-renders.
// To fix this, we could return an additional state flag like `streamReady` that
// changes when the stream is set, and consumers would add it to their effect deps.
const streamRef = useRef<MediaStream | null>(null);
/**
* Initialize canvas, stream, and default icon on mount.
*/
useEffect(() => {
// Create canvas.
const canvas = document.createElement('canvas');
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
refs.current.canvas = canvas;
// Create stream from canvas.
streamRef.current = canvas.captureStream(CANVAS_FRAME_RATE);
// Load default icon.
refs.current.defaultIcon = createDefaultIconImage();
logger.log('Canvas avatar initialized');
// Cleanup on unmount.
return () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
refs.current.canvas = null;
refs.current.defaultIcon = null;
logger.log('Canvas avatar cleaned up');
};
}, []);
/**
* Re-render avatar when participant or display name changes.
*/
useEffect(() => {
const { canvas, defaultIcon } = refs.current;
if (!canvas) {
return;
}
const ctx = canvas.getContext('2d');
if (!ctx) {
logger.error('Failed to get canvas 2D context');
return;
}
renderAvatarOnCanvas(
canvas,
ctx,
participant,
displayName,
customAvatarBackgrounds,
defaultIcon,
backgroundColor,
fontFamily,
initialsColor,
displayNameColor
).then(() => {
// Request a frame capture after drawing.
// For captureStream(0), we need to manually trigger frame capture.
const track = streamRef.current?.getVideoTracks()[0] as MediaStreamTrack & { requestFrame?: () => void; };
if (track?.requestFrame) {
track.requestFrame();
logger.log('Canvas frame requested after render');
}
}).catch((error: Error) => logger.error('Error rendering avatar on canvas:', error));
}, [ participant?.loadableAvatarUrl, participant?.name, displayName, customAvatarBackgrounds, backgroundColor, fontFamily, initialsColor, displayNameColor ]);
return {
canvasStreamRef: streamRef
};
}

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('app:pip');

View File

@@ -0,0 +1 @@
import './subscriber';

View File

@@ -0,0 +1,30 @@
import ReducerRegistry from '../base/redux/ReducerRegistry';
import { SET_PIP_ACTIVE } from './actionTypes';
/**
* The default state for the pip feature.
*/
const DEFAULT_STATE = {
isPiPActive: false
};
export interface IPipState {
isPiPActive: boolean;
}
/**
* Reduces the Redux actions of the pip feature.
*/
ReducerRegistry.register<IPipState>('features/pip', (state = DEFAULT_STATE, action): IPipState => {
switch (action.type) {
case SET_PIP_ACTIVE:
return {
...state,
isPiPActive: action.isPiPActive
};
default:
return state;
}
});

View File

@@ -0,0 +1,61 @@
import { IReduxState } from '../app/types';
import { MEDIA_TYPE } from '../base/media/constants';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import { isLocalTrackMuted } from '../base/tracks/functions.any';
import { getElectronGlobalNS } from '../base/util/helpers';
import { requestPictureInPicture, shouldShowPiP, updateMediaSessionState } from './functions';
/**
* Listens to audio and video mute state changes when PiP is active
* and updates the MediaSession API to reflect the current state in PiP controls.
*/
StateListenerRegistry.register(
/* selector */ (state: IReduxState) => {
// Skip if PiP is disabled or shouldn't be shown (e.g., on prejoin without showOnPrejoin).
if (!shouldShowPiP(state)) {
return null;
}
const isPiPActive = state['features/pip']?.isPiPActive;
if (!isPiPActive) {
return null;
}
return {
audioMuted: isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO),
videoMuted: isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.VIDEO)
};
},
/* listener */ (muteState: { audioMuted: boolean; videoMuted: boolean; } | null) => {
if (muteState === null) {
return;
}
updateMediaSessionState({
cameraActive: !muteState.videoMuted,
microphoneActive: !muteState.audioMuted
});
},
{
deepEquals: true
}
);
StateListenerRegistry.register(
/* selector */ shouldShowPiP,
/* listener */ (_shouldShowPiP: boolean) => {
const electronNS = getElectronGlobalNS();
if (_shouldShowPiP) {
// Expose requestPictureInPicture for Electron main process.
if (!electronNS.requestPictureInPicture) {
electronNS.requestPictureInPicture = requestPictureInPicture;
}
} else if (typeof electronNS.requestPictureInPicture === 'function') {
delete electronNS.requestPictureInPicture;
}
}
);

View File

@@ -0,0 +1,14 @@
/**
* MediaSession state for microphone and camera.
*/
export interface IMediaSessionState {
/**
* Whether the camera is active (unmuted).
*/
cameraActive: boolean;
/**
* Whether the microphone is active (unmuted).
*/
microphoneActive: boolean;
}

View File

@@ -41,7 +41,8 @@ const useStyles = makeStyles<{ deviceStatusType?: string; }>()((theme, { deviceS
width: '16px',
height: '16px',
borderRadius: '100%',
backgroundColor: deviceStatusType === 'ok' ? theme.palette.success01 : ColorPalette.darkGrey
backgroundColor: deviceStatusType === 'ok' ? theme.palette.success01 : ColorPalette.darkGrey,
flexShrink: 0
}
};
});

View File

@@ -1,5 +1,6 @@
import React, { ComponentType, PureComponent } from 'react';
import { SafeAreaView, TouchableWithoutFeedback, View } from 'react-native';
import { TouchableWithoutFeedback } from 'react-native';
import { Edge, SafeAreaView } from 'react-native-safe-area-context';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
@@ -8,6 +9,7 @@ import { hideDialog } from '../../../base/dialog/actions';
import { isDialogOpen } from '../../../base/dialog/functions';
import { getParticipantCount } from '../../../base/participants/functions';
import { StyleType } from '../../../base/styles/functions.native';
import { isToolboxVisible } from '../../../toolbox/functions.native';
import ReactionMenu from './ReactionMenu';
@@ -36,6 +38,11 @@ interface IProps {
*/
_styles: StyleType;
/**
* The indicator which determines whether the Toolbox is visible.
*/
_toolboxVisible: boolean;
/**
* The width of the screen.
*/
@@ -80,25 +87,24 @@ class ReactionMenuDialog extends PureComponent<IProps> {
* @returns {ReactElement}
*/
override render() {
const { _styles, _width, _height, _participantCount } = this.props;
const { _height, _participantCount, _styles, _toolboxVisible, _width } = this.props;
return (
<SafeAreaView style = { _styles }>
<TouchableWithoutFeedback
onPress = { this._onCancel }>
<View style = { _styles }>
<View
style = {{
left: (_width - 360) / 2,
top: _height - (_participantCount > 1 ? 144 : 80) - 80
}}>
<ReactionMenu
onCancel = { this._onCancel }
overflowMenu = { false } />
</View>
</View>
</TouchableWithoutFeedback>
</SafeAreaView>
<TouchableWithoutFeedback
onPress = { this._onCancel }>
<SafeAreaView
edges = { [ 'bottom', 'left', 'right', !_toolboxVisible && 'top' ].filter(Boolean) as Edge[] }
style = { [
_styles,
{
left: (_width - 360) / 2,
top: _height - (_participantCount > 1 ? 144 : 80) - 80
} ] }>
<ReactionMenu
onCancel = { this._onCancel }
overflowMenu = { false } />
</SafeAreaView>
</TouchableWithoutFeedback>
);
}
@@ -132,7 +138,8 @@ function _mapStateToProps(state: IReduxState) {
_styles: ColorSchemeRegistry.get(state, 'Toolbox').reactionDialog,
_width: state['features/base/responsive-ui'].clientWidth,
_height: state['features/base/responsive-ui'].clientHeight,
_participantCount: getParticipantCount(state)
_participantCount: getParticipantCount(state),
_toolboxVisible: isToolboxVisible(state)
};
}

View File

@@ -9,7 +9,7 @@ import { openSheet } from '../../base/dialog/actions';
import { translate } from '../../base/i18n/functions';
import NavigateSectionList from '../../base/react/components/native/NavigateSectionList';
import { Item, Section } from '../../base/react/types';
import styles from '../../welcome/components/styles';
import styles from '../../welcome/components/styles.native';
import { isRecentListEnabled, toDisplayableList } from '../functions.native';
import AbstractRecentList from './AbstractRecentList';

View File

@@ -30,10 +30,7 @@ import {
START_LOCAL_RECORDING,
STOP_LOCAL_RECORDING
} from './actionTypes';
import {
RECORDING_METADATA_ID,
START_RECORDING_NOTIFICATION_ID
} from './constants';
import { START_RECORDING_NOTIFICATION_ID } from './constants';
import {
getRecordButtonProps,
getRecordingLink,
@@ -462,10 +459,7 @@ export function showStartRecordingNotificationWithCallback(openRecordingDialog:
});
if (autoTranscribeOnRecord) {
conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: true
});
dispatch(setRequestingSubtitles(true, false, null, true));
dispatch(setRequestingSubtitles(true, false, null));
}
} else {
openRecordingDialog();

View File

@@ -63,11 +63,6 @@ export interface IProps extends WithTranslation {
*/
_rToken: string;
/**
* Whether the record audio / video option is enabled by default.
*/
_recordAudioAndVideo: boolean;
/**
* Whether or not the local participant is screensharing.
*/
@@ -99,6 +94,11 @@ export interface IProps extends WithTranslation {
dispatch: IStore['dispatch'];
navigation: any;
/**
* Whether the record audio / video option is enabled by default.
*/
recordAudioAndVideo: boolean;
}
interface IState {
@@ -191,7 +191,7 @@ class AbstractStartRecordingDialog extends Component<IProps, IState> {
isValidating: false,
userName: undefined,
sharingEnabled: true,
shouldRecordAudioAndVideo: this.props._recordAudioAndVideo,
shouldRecordAudioAndVideo: this.props.recordAudioAndVideo,
shouldRecordTranscription: this.props._autoTranscribeOnRecord,
spaceLeft: undefined,
selectedRecordingService,
@@ -415,13 +415,13 @@ class AbstractStartRecordingDialog extends Component<IProps, IState> {
if (this.state.selectedRecordingService === RECORDING_TYPES.JITSI_REC_SERVICE
&& this.state.shouldRecordTranscription) {
dispatch(setRequestingSubtitles(true, _displaySubtitles, _subtitlesLanguage, true));
dispatch(setRequestingSubtitles(true, _displaySubtitles, _subtitlesLanguage));
} else {
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: this.state.shouldRecordTranscription
});
}
_conference?.getMetadataHandler().setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: this.state.shouldRecordTranscription
});
return true;
}
@@ -474,7 +474,7 @@ export function mapStateToProps(state: IReduxState, _ownProps: any) {
_isDropboxEnabled: isDropboxEnabled(state),
_localRecordingEnabled: !localRecording?.disable,
_rToken: state['features/dropbox'].rToken ?? '',
_recordAudioAndVideo: recordings?.recordAudioAndVideo ?? true,
recordAudioAndVideo: _ownProps.recordAudioAndVideo ?? recordings?.recordAudioAndVideo ?? true,
_subtitlesLanguage,
_tokenExpireDate: state['features/dropbox'].expireDate,
_token: state['features/dropbox'].token ?? ''

View File

@@ -180,8 +180,8 @@ const LocalRecordingManager: ILocalRecordingManager = {
this.selfRecording.withVideo = Boolean(videoTrack);
const localTracks: MediaStreamTrack[] = [];
audioTrack && localTracks.push(audioTrack);
videoTrack && localTracks.push(videoTrack);
audioTrack && localTracks.push(audioTrack.clone());
videoTrack && localTracks.push(videoTrack.clone());
this.stream = new MediaStream(localTracks);
} else {
if (supportsCaptureHandle) {
@@ -213,13 +213,16 @@ const LocalRecordingManager: ILocalRecordingManager = {
});
const gdmVideoTrack = gdmStream.getVideoTracks()[0];
const isBrowser = gdmVideoTrack.getSettings().displaySurface === 'browser';
const matchesHandle = (supportsCaptureHandle // @ts-ignore
&& gdmVideoTrack.getCaptureHandle()?.handle === `JitsiMeet-${tabId}`);
if (!isBrowser || !matchesHandle) {
gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
throw new Error('WrongSurfaceSelected');
if (supportsCaptureHandle) {
const isBrowser = gdmVideoTrack.getSettings().displaySurface === 'browser';
const matchesHandle = (supportsCaptureHandle // @ts-ignore
&& gdmVideoTrack.getCaptureHandle()?.handle === `JitsiMeet-${tabId}`);
if (!isBrowser || !matchesHandle) {
gdmStream.getTracks().forEach((track: MediaStreamTrack) => track.stop());
throw new Error('WrongSurfaceSelected');
}
}
this.initializeAudioMixer();
@@ -279,6 +282,9 @@ const LocalRecordingManager: ILocalRecordingManager = {
this.audioContext = undefined;
this.audioDestination = undefined;
this.startTime = undefined;
this.stream = undefined;
this.selfRecording.on = false;
this.selfRecording.withVideo = false;
if (this.writableStream) {
try {

View File

@@ -1,6 +1,7 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, SafeAreaView, ScrollView, Text, View, ViewStyle } from 'react-native';
import { Platform, ScrollView, Text, View, ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';

View File

@@ -2,6 +2,7 @@ import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useLayoutEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, Text, TouchableHighlight, View, ViewStyle } from 'react-native';
import { Edge } from 'react-native-safe-area-context';
import { useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
@@ -45,9 +46,7 @@ const LanguageSelectView = ({ isInWelcomePage }: { isInWelcomePage?: boolean; })
return (
<JitsiScreen
disableForcedKeyboardDismiss = { true }
// @ts-ignore
safeAreaInsets = { [ !isInWelcomePage && 'bottom', 'left', 'right' ].filter(Boolean) }
safeAreaInsets = { [ !isInWelcomePage && 'bottom', 'left', 'right' ].filter(Boolean) as Edge[] }
style = { styles.settingsViewContainer }>
<ScrollView
bounces = { isInWelcomePage }

View File

@@ -115,8 +115,6 @@ const ProfileView = ({ isInWelcomePage }: {
<JitsiScreen
disableForcedKeyboardDismiss = { true }
hasBottomTextInput = { true }
// @ts-ignore
safeAreaInsets = { [ !isInWelcomePage && 'bottom', 'left', 'right' ].filter(Boolean) as Edge[] }
style = { styles.settingsViewContainer }>
<ScrollView

View File

@@ -27,11 +27,6 @@ export interface IProps extends AbstractDialogTabProps, WithTranslation {
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* Whether to hide chat with permissions.
*/
disableChatWithPermissions: boolean;
/**
* If set hides the reactions moderation setting.
*/
@@ -57,6 +52,11 @@ export interface IProps extends AbstractDialogTabProps, WithTranslation {
*/
followMeRecorderEnabled: boolean;
/**
* Whether to hide chat with permissions.
*/
hideChatWithPermissions: boolean;
/**
* Whether or not the user has selected the Start Audio Muted feature to be
* enabled.
@@ -210,12 +210,12 @@ class ModeratorTab extends AbstractDialogTab<IProps, any> {
const {
audioModerationEnabled,
chatWithPermissionsEnabled,
disableChatWithPermissions,
disableReactionsModeration,
followMeActive,
followMeEnabled,
followMeRecorderActive,
followMeRecorderEnabled,
hideChatWithPermissions,
startAudioMuted,
startVideoMuted,
startReactionsMuted,
@@ -266,7 +266,7 @@ class ModeratorTab extends AbstractDialogTab<IProps, any> {
label = { t('settings.startReactionsMuted') }
name = 'start-reactions-muted'
onChange = { this._onStartReactionsMutedChanged } /> }
{ !disableChatWithPermissions
{ !hideChatWithPermissions
&& <Checkbox
checked = { chatWithPermissionsEnabled }
className = { classes.checkbox }

View File

@@ -162,12 +162,12 @@ export function getModeratorTabProps(stateful: IStateful) {
} = state['features/base/conference'];
const { followMeEnabled, followMeRecorderEnabled } = state['features/follow-me'];
const { groupChatWithPermissions } = state['features/chat'];
const { disableReactionsModeration } = state['features/base/config'];
const { showChatPermissionsModeratorSetting, disableReactionsModeration } = state['features/base/config'];
const followMeActive = isFollowMeActive(state);
const followMeRecorderActive = isFollowMeRecorderActive(state);
const showModeratorSettings = shouldShowModeratorSettings(state);
const conferenceMetadata = conference?.getMetadataHandler()?.getMetadata();
const disableChatWithPermissions = !conferenceMetadata?.allownersEnabled;
const hideChatWithPermissions = !showChatPermissionsModeratorSetting || conferenceMetadata?.allownersEnabled;
const isAudioModerationEnabled = isEnabledFromState(MEDIA_TYPE.AUDIO, state);
const isVideoModerationEnabled = isEnabledFromState(MEDIA_TYPE.VIDEO, state);
@@ -177,12 +177,12 @@ export function getModeratorTabProps(stateful: IStateful) {
videoModerationEnabled: isVideoModerationEnabled,
chatWithPermissionsEnabled: Boolean(groupChatWithPermissions),
showModeratorSettings: Boolean(conference && showModeratorSettings),
disableChatWithPermissions: Boolean(disableChatWithPermissions),
disableReactionsModeration: Boolean(disableReactionsModeration),
followMeActive: Boolean(conference && followMeActive),
followMeEnabled: Boolean(conference && followMeEnabled),
followMeRecorderActive: Boolean(conference && followMeRecorderActive),
followMeRecorderEnabled: Boolean(conference && followMeRecorderEnabled),
hideChatWithPermissions: Boolean(hideChatWithPermissions),
startReactionsMuted: Boolean(conference && startReactionsMuted),
startAudioMuted: Boolean(conference && startAudioMutedPolicy),
startVideoMuted: Boolean(conference && startVideoMutedPolicy)

View File

@@ -95,11 +95,9 @@ export function toggleRequestingSubtitles() {
export function setRequestingSubtitles(
enabled: boolean,
displaySubtitles = true,
language: string | null = `translation-languages:${DEFAULT_LANGUAGE}`,
backendRecordingOn = false) {
language: string | null = `translation-languages:${DEFAULT_LANGUAGE}`) {
return {
type: SET_REQUESTING_SUBTITLES,
backendRecordingOn,
displaySubtitles,
enabled,
language

View File

@@ -3,10 +3,11 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState, IStore } from '../../app/types';
import { openDialog } from '../../base/dialog/actions';
import { StartRecordingDialog } from '../../recording/components/Recording/index';
import { setRequestingSubtitles } from '../actions.any';
import { getAvailableSubtitlesLanguages } from '../functions.any';
export interface IAbstractLanguageSelectorDialogProps {
dispatch: IStore['dispatch'];
language: string | null;
@@ -44,14 +45,21 @@ const AbstractLanguageSelectorDialog = (Component: ComponentType<IAbstractLangua
selected: lang === selected
};
});
const { conference } = useSelector((state: IReduxState) => state['features/base/conference']);
const onLanguageSelected = useCallback((value: string) => {
const _selectedLanguage = value === noLanguageLabel ? null : value;
const enabled = Boolean(_selectedLanguage);
const displaySubtitles = enabled;
dispatch(setRequestingSubtitles(enabled, displaySubtitles, _selectedLanguage));
}, [ language ]);
if (conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription) {
dispatch(openDialog('StartRecordingDialog', StartRecordingDialog, {
recordAudioAndVideo: false
}));
} else {
dispatch(setRequestingSubtitles(enabled, displaySubtitles, _selectedLanguage));
}
}, [ conference, language ]);
return (
<Component

View File

@@ -1,12 +1,14 @@
import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { ENDPOINT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
import { ENDPOINT_MESSAGE_RECEIVED, NON_PARTICIPANT_MESSAGE_RECEIVED } from '../base/conference/actionTypes';
import { MEET_FEATURES } from '../base/jwt/constants';
import { isJwtFeatureEnabled } from '../base/jwt/functions';
import JitsiMeetJS from '../base/lib-jitsi-meet';
import { TRANSCRIBER_ID } from '../base/participants/constants';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import { showErrorNotification } from '../notifications/actions';
import { RECORDING_METADATA_ID } from '../recording/constants';
import { TRANSCRIBER_JOINED } from '../transcribing/actionTypes';
import {
@@ -76,6 +78,7 @@ const STABLE_TRANSCRIPTION_FACTOR = 0.85;
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case ENDPOINT_MESSAGE_RECEIVED:
case NON_PARTICIPANT_MESSAGE_RECEIVED:
return _endpointMessageReceived(store, next, action);
case TOGGLE_REQUESTING_SUBTITLES: {
@@ -95,7 +98,7 @@ MiddlewareRegistry.register(store => next => action => {
break;
}
case SET_REQUESTING_SUBTITLES:
_requestingSubtitlesChange(store, action.enabled, action.language, action.backendRecordingOn);
_requestingSubtitlesChange(store, action.enabled, action.language);
break;
}
@@ -117,7 +120,18 @@ MiddlewareRegistry.register(store => next => action => {
* @returns {Object} The value returned by {@code next(action)}.
*/
function _endpointMessageReceived(store: IStore, next: Function, action: AnyAction) {
const { data: json } = action;
let json: any = {};
if (action.type === ENDPOINT_MESSAGE_RECEIVED) {
if (!action.participant.isHidden()) {
return next(action);
}
json = action.data;
} else if (action.type === NON_PARTICIPANT_MESSAGE_RECEIVED && action.id === TRANSCRIBER_ID) {
json = action.json;
} else {
return next(action);
}
if (![ JSON_TYPE_TRANSCRIPTION_RESULT, JSON_TYPE_TRANSLATION_RESULT ].includes(json?.type)) {
return next(action);
@@ -330,31 +344,25 @@ function _getPrimaryLanguageCode(language: string) {
* @param {Store} store - The redux store.
* @param {boolean} enabled - Whether subtitles should be enabled or not.
* @param {string} language - The language to use for translation.
* @param {boolean} backendRecordingOn - Whether backend recording is on or not.
* @private
* @returns {void}
*/
function _requestingSubtitlesChange(
{ dispatch, getState }: IStore,
enabled: boolean,
language?: string | null,
backendRecordingOn = false) {
language?: string | null) {
const state = getState();
const { conference } = state['features/base/conference'];
const { transcription } = state['features/base/config'];
const backendRecordingOn = conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription;
conference?.setLocalParticipantProperty(
P_NAME_REQUESTING_TRANSCRIPTION,
enabled);
if (enabled && conference?.getTranscriptionStatus() === JitsiMeetJS.constants.transcriptionStatus.OFF) {
const featureAllowed = isJwtFeatureEnabled(getState(), MEET_FEATURES.TRANSCRIPTION, false);
if (enabled && conference?.getTranscriptionStatus() === JitsiMeetJS.constants.transcriptionStatus.OFF
&& isJwtFeatureEnabled(getState(), MEET_FEATURES.TRANSCRIPTION, false)) {
// the default value for inviteJigasiOnBackendTranscribing is true (when undefined)
const inviteJigasi = conference?.getMetadataHandler()?.getMetadata()?.asyncTranscription
? (transcription?.inviteJigasiOnBackendTranscribing ?? true) : true;
if (featureAllowed && (!backendRecordingOn || inviteJigasi)) {
if (!backendRecordingOn) {
conference?.dial(TRANSCRIBER_DIAL_NUMBER)
.catch((e: any) => {
logger.error('Error dialing', e);
@@ -367,6 +375,10 @@ function _requestingSubtitlesChange(
}));
dispatch(setSubtitlesError(true));
});
} else {
conference?.getMetadataHandler()?.setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: true
});
}
}
@@ -375,6 +387,13 @@ function _requestingSubtitlesChange(
P_NAME_TRANSLATION_LANGUAGE,
language.replace('translation-languages:', ''));
}
if (!enabled && backendRecordingOn
&& conference?.getMetadataHandler()?.getMetadata()[RECORDING_METADATA_ID]?.isTranscribingEnabled) {
conference?.getMetadataHandler()?.setMetadata(RECORDING_METADATA_ID, {
isTranscribingEnabled: false
});
}
}
/**

View File

@@ -50,6 +50,13 @@ export function setToolboxVisible(visible: boolean) {
type: SET_TOOLBOX_VISIBLE,
visible
});
// Notify external API consumers about the change in toolbox visibility
// if the old legacy APP.API bridge is available.
/* eslint-disable no-undef */
if (typeof APP !== 'undefined' && APP.API && typeof APP.API.notifyToolbarVisibilityChanged === 'function') {
APP.API.notifyToolbarVisibilityChanged(visible);
}
/* eslint-enable no-undef */
};
}
@@ -72,6 +79,18 @@ export function toggleToolboxVisible() {
dispatch({
type: TOGGLE_TOOLBOX_VISIBLE
});
// After toggling, read the updated state and notify external API
// about the current visibility. This mirrors the behavior of
// setToolboxVisible and ensures consumers are informed when the
// visibility changes via toggle.
/* eslint-disable no-undef */
if (typeof APP !== 'undefined' && APP.API && typeof APP.API.notifyToolbarVisibilityChanged === 'function') {
const { visible: newVisible } = getState()['features/toolbox'];
APP.API.notifyToolbarVisibilityChanged(newVisible);
}
/* eslint-enable no-undef */
};
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { View, ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Edge, SafeAreaView } from 'react-native-safe-area-context';
import { connect, useSelector } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
@@ -117,9 +117,7 @@ function Toolbox(props: IProps) {
style = { styles.toolboxContainer as ViewStyle }>
<SafeAreaView
accessibilityRole = 'toolbar'
// @ts-ignore
edges = { [ bottomEdge && 'bottom' ].filter(Boolean) }
edges = { [ bottomEdge && 'bottom' ].filter(Boolean) as Edge[] }
pointerEvents = 'box-none'
style = { style as ViewStyle }>
{ renderToolboxButtons() }

View File

@@ -19,6 +19,7 @@ import {
import {
getJwtDisabledButtons,
getVisibleButtons,
getVisibleButtonsForReducedUI,
isButtonEnabled,
isToolboxVisible
} from '../../functions.web';
@@ -82,8 +83,7 @@ export default function Toolbox({
const isNarrowLayout = useSelector((state: IReduxState) => state['features/base/responsive-ui'].isNarrowLayout);
const videoSpaceWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].videoSpaceWidth);
const isModerator = useSelector(isLocalParticipantModerator);
const customToolbarButtons = useSelector(
(state: IReduxState) => state['features/base/config'].customToolbarButtons);
const customToolbarButtons = useSelector((state: IReduxState) => state['features/base/config'].customToolbarButtons);
const iAmRecorder = useSelector((state: IReduxState) => state['features/base/config'].iAmRecorder);
const iAmSipGateway = useSelector((state: IReduxState) => state['features/base/config'].iAmSipGateway);
const overflowDrawer = useSelector((state: IReduxState) => state['features/toolbox'].overflowDrawer);
@@ -110,6 +110,8 @@ export default function Toolbox({
const toolbarVisible = useSelector(isToolboxVisible);
const mainToolbarButtonsThresholds
= useSelector((state: IReduxState) => state['features/toolbox'].mainToolbarButtonsThresholds);
const { reducedUImainToolbarButtons } = useSelector((state: IReduxState) => state['features/base/config']);
const reducedUI = useSelector((state: IReduxState) => state['features/base/responsive-ui'].reducedUI);
const allButtons = useToolboxButtons(customToolbarButtons);
const isMobile = isMobileBrowser();
const endConferenceSupported = Boolean(conference?.isEndConferenceSupported() && isModerator);
@@ -233,7 +235,7 @@ export default function Toolbox({
const toolbarAccLabel = 'toolbar.accessibilityLabel.moreActionsMenu';
const containerClassName = `toolbox-content${isMobile || isNarrowLayout ? ' toolbox-content-mobile' : ''}`;
const { mainMenuButtons, overflowMenuButtons } = getVisibleButtons({
const normalUIButtons = getVisibleButtons({
allButtons,
buttonsWithNotifyClick,
toolbarButtons: toolbarButtonsToUse,
@@ -241,6 +243,20 @@ export default function Toolbox({
jwtDisabledButtons,
mainToolbarButtonsThresholds
});
const reducedUIButtons = getVisibleButtonsForReducedUI({
allButtons,
buttonsWithNotifyClick,
jwtDisabledButtons,
reducedUImainToolbarButtons,
});
const mainMenuButtons = reducedUI
? reducedUIButtons.mainMenuButtons
: normalUIButtons.mainMenuButtons;
const overflowMenuButtons = reducedUI
? []
: normalUIButtons.overflowMenuButtons;
const raiseHandInOverflowMenu = overflowMenuButtons.some(({ key }) => key === 'raisehand');
const showReactionsInOverflowMenu = _shouldDisplayReactionsButtons
&& (

View File

@@ -12,6 +12,8 @@ export const DUMMY_9_BUTTONS_THRESHOLD_VALUE = Symbol('9_BUTTONS_THRESHOLD_VALUE
*/
export const DUMMY_10_BUTTONS_THRESHOLD_VALUE = Symbol('10_BUTTONS_THRESHOLD_VALUE');
export const DEFAULT_REDUCED_UI_MAIN_TOOLBAR_BUTTONS = [ 'microphone', 'camera' ];
/**
* Thresholds for displaying toolbox buttons.
*/

View File

@@ -6,9 +6,9 @@ import { IGUMPendingState } from '../base/media/types';
import { isScreenMediaShared } from '../screen-share/functions';
import { isWhiteboardVisible } from '../whiteboard/functions';
import { MAIN_TOOLBAR_BUTTONS_PRIORITY, TOOLBAR_TIMEOUT } from './constants';
import { DEFAULT_REDUCED_UI_MAIN_TOOLBAR_BUTTONS, MAIN_TOOLBAR_BUTTONS_PRIORITY, TOOLBAR_TIMEOUT } from './constants';
import { isButtonEnabled } from './functions.any';
import { IGetVisibleButtonsParams, IToolboxButton, NOTIFY_CLICK_MODE } from './types';
import { IGetVisibleButtonsForReducedUIParams, IGetVisibleButtonsParams, IToolboxButton, NOTIFY_CLICK_MODE } from './types';
export * from './functions.any';
@@ -201,6 +201,41 @@ export function getVisibleButtons({
};
}
/**
* Returns buttons that need to be rendered for reduced UI mode.
*
* @param {IGetVisibleButtonsForReducedUIParams} params - The parameters needed to extract the visible buttons.
* @returns {Object} - The visible buttons for reduced ui.
*/
export function getVisibleButtonsForReducedUI({
allButtons,
buttonsWithNotifyClick,
jwtDisabledButtons,
reducedUImainToolbarButtons
}: IGetVisibleButtonsForReducedUIParams) {
setButtonsNotifyClickMode(allButtons, buttonsWithNotifyClick);
if (!Array.isArray(reducedUImainToolbarButtons) || reducedUImainToolbarButtons.length === 0) {
const defaultButtons = DEFAULT_REDUCED_UI_MAIN_TOOLBAR_BUTTONS.map(key => allButtons[key]);
return {
mainMenuButtons: defaultButtons
};
}
const filteredButtons = reducedUImainToolbarButtons.filter(key =>
typeof key !== 'undefined'
&& !jwtDisabledButtons.includes(key)
&& isButtonEnabled(key, reducedUImainToolbarButtons)
&& allButtons[key]);
const mainMenuButtons = filteredButtons.map(key => allButtons[key]);
return {
mainMenuButtons
};
}
/**
* Returns the list of participant menu buttons that have that notify the api when clicked.
*

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