mirror of
https://gitcode.com/GitHub_Trending/ji/jitsi-meet.git
synced 2026-01-20 21:50:21 +00:00
Compare commits
92 Commits
8917
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d32907178 | ||
|
|
481b9a6e58 | ||
|
|
fb3bc3c367 | ||
|
|
9fa5489154 | ||
|
|
9499bf29ed | ||
|
|
f605b5c487 | ||
|
|
88fba5acab | ||
|
|
7bc79bc144 | ||
|
|
3e469019b5 | ||
|
|
d324935501 | ||
|
|
cd11cf6f65 | ||
|
|
5db3d529f4 | ||
|
|
c7d2c9c204 | ||
|
|
9832c7a226 | ||
|
|
12ee929499 | ||
|
|
b9ed42613b | ||
|
|
0d572b3bfb | ||
|
|
609eab5f83 | ||
|
|
7bd0f479b9 | ||
|
|
a634b6b2bc | ||
|
|
0e53bd87ce | ||
|
|
9e89c33796 | ||
|
|
eaffd8b8f7 | ||
|
|
b8444d56ff | ||
|
|
42b2dd41c1 | ||
|
|
229d1823fb | ||
|
|
cd6e905b95 | ||
|
|
ddaf7a3180 | ||
|
|
47aa51a58c | ||
|
|
66f7b9de53 | ||
|
|
bab87be9c9 | ||
|
|
6549d47233 | ||
|
|
2063e66b8e | ||
|
|
4dd241712d | ||
|
|
a574d5ec79 | ||
|
|
4b2b85bd12 | ||
|
|
77ab1ea8ed | ||
|
|
05e47ade7c | ||
|
|
6c78ec9099 | ||
|
|
4c5afc0b5e | ||
|
|
2e31ab9dca | ||
|
|
02787b1394 | ||
|
|
2476a06237 | ||
|
|
2d8909911e | ||
|
|
d06b847319 | ||
|
|
b517f614b3 | ||
|
|
10f77f1fbc | ||
|
|
77b89ece4a | ||
|
|
129264c3c9 | ||
|
|
96c5a9abd1 | ||
|
|
93ef2337ae | ||
|
|
854a077684 | ||
|
|
f903a7ae6e | ||
|
|
9013881f76 | ||
|
|
b6e7e0a19e | ||
|
|
ae42e42534 | ||
|
|
21e2504cf9 | ||
|
|
7a9ba79783 | ||
|
|
1f5a3b5b0f | ||
|
|
fe2aff4f3c | ||
|
|
d847f6f96b | ||
|
|
45ce467dcd | ||
|
|
2b81fa6bd3 | ||
|
|
6f6100ceb2 | ||
|
|
62cd1c29d7 | ||
|
|
64869e8970 | ||
|
|
29464e6886 | ||
|
|
5ed92f2bc5 | ||
|
|
048d12de24 | ||
|
|
40c240c7ca | ||
|
|
289c1907e7 | ||
|
|
35adea48ae | ||
|
|
d72114d5bc | ||
|
|
2f6b6ca837 | ||
|
|
615bbdc39b | ||
|
|
ef97778158 | ||
|
|
2885f39355 | ||
|
|
ae256b23b8 | ||
|
|
412aa83268 | ||
|
|
f4c61e4760 | ||
|
|
f313fb81d0 | ||
|
|
975af80e27 | ||
|
|
0a30a51bab | ||
|
|
54e28e223c | ||
|
|
a4def96763 | ||
|
|
dad4fb9e06 | ||
|
|
3772b9a5ae | ||
|
|
89b9c75242 | ||
|
|
b24b60b735 | ||
|
|
486a1f6511 | ||
|
|
80b3f1d7d4 | ||
|
|
421b21edeb |
12
.github/dependabot.yml
vendored
Normal file
12
.github/dependabot.yml
vendored
Normal 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)"
|
||||
2
.github/workflows/ci-lua.yml
vendored
2
.github/workflows/ci-lua.yml
vendored
@@ -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
|
||||
|
||||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -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'
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -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.'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
13
config.js
13
config.js
@@ -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
|
||||
@@ -907,6 +907,8 @@ var config = {
|
||||
// alwaysVisible: false,
|
||||
// // Indicates whether the toolbar should still autohide when chat is open
|
||||
// autoHideWhileChatIsOpen: false,
|
||||
// // Default background color for the main toolbar. Accepts any valid CSS color.
|
||||
// // backgroundColor: '#ffffff',
|
||||
// },
|
||||
|
||||
// Overrides the buttons displayed in the main toolbar. Depending on the screen size the number of displayed
|
||||
@@ -925,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
5
custom.d.ts
vendored
@@ -2,3 +2,8 @@ declare module '*.svg' {
|
||||
const content: any;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.svg?raw' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
|
||||
7
debian/jitsi-meet-prosody.postinst
vendored
7
debian/jitsi-meet-prosody.postinst
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"it": "Italiano",
|
||||
"ja": "日本語",
|
||||
"kab": "Taqbaylit",
|
||||
"kk": "Қазақша",
|
||||
"ko": "한국어",
|
||||
"lt": "Lietuvių",
|
||||
"lv": "Latviešu",
|
||||
|
||||
1895
lang/main-da.json
1895
lang/main-da.json
File diff suppressed because it is too large
Load Diff
@@ -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,6 +1569,8 @@
|
||||
"connectionInfo": "Verbindungsinformationen",
|
||||
"demote": "Zu Gästen verschieben",
|
||||
"domute": "Stummschalten",
|
||||
"domuteDesktop": "Bildschirmfreigabe beenden",
|
||||
"domuteDesktopOfOthers": "Bildschirmfreigabe für alle beenden",
|
||||
"domuteOthers": "Alle anderen stummschalten",
|
||||
"domuteVideo": "Kamera ausschalten",
|
||||
"domuteVideoOfOthers": "Alle anderen Kameras auschalten",
|
||||
@@ -1548,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!"
|
||||
|
||||
@@ -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 l’accesso 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
1703
lang/main-kk.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -114,6 +114,9 @@
|
||||
"error": "Kļūda: Jūsu ziņa netika nosūtīta. Cēlonis: {{error}}",
|
||||
"everyone": "Visi",
|
||||
"fieldPlaceHolder": "Rakstiet ziņu šeit",
|
||||
"fileAccessibleTitle": "{{user}} augšuplādēja failu",
|
||||
"fileAccessibleTitleMe": "es augšuplādēju failu",
|
||||
"fileDeleted": "Fails tika dzēsts",
|
||||
"guestsChatIndicator": "(viesis)",
|
||||
"lobbyChatMessageTo": "Vestibila tērzēšanas ziņa adresātam {{recipient}}",
|
||||
"message": "Ziņa",
|
||||
@@ -123,12 +126,20 @@
|
||||
"messagebox": "Rakstiet ziņu",
|
||||
"newMessages": "Jaunas ziņas",
|
||||
"nickname": {
|
||||
"popover": "Izvēlieties vārdu",
|
||||
"title": "Ierakstiet vārdu, lai izmantotu tērzēšanā",
|
||||
"titleWithCC": "Ievadiet segvārdu, lai izmantotu tērzēšanā un slēptos subtitros",
|
||||
"titleWithPolls": "Ierakstiet segvārdu, lai izmantotu tērzēšanā un aptaujās",
|
||||
"titleWithPollsAndCC": "Ievadiet segvārdu, lai izmantotu tērzēšanā, aptaujās un slēptos subtitros",
|
||||
"titleWithPollsAndCCAndFileSharing": "Ievadiet segvārdu, lai izmantotu tērzēšanā, aptaujās, slēptos subtitros un failos"
|
||||
"featureChat": "tērzētava",
|
||||
"featureClosedCaptions": "slēgtie subtitri",
|
||||
"featureFileSharing": "failu kopīgošana",
|
||||
"featurePolls": "aptaujas",
|
||||
"popover": "Izvēlieties segvārdu",
|
||||
"title": "Ierakstiet segvārdu, lai izmantotu tērzēšanu",
|
||||
"titleWith1Features": "Ievadiet segvārdu, lai izmantotu {{feature1}}",
|
||||
"titleWith2Features": "Ievadiet segvārdu, lai izmantotu {{feature1}} un {{feature2}}",
|
||||
"titleWith3Features": "Ievadiet segvārdu, lai izmantotu {{feature1}}, {{feature2}} un {{feature3}}",
|
||||
"titleWith4Features": "Ievadiet segvārdu, lai izmantotu {{feature1}}, {{feature2}}, {{feature3}} un {{feature4}}",
|
||||
"titleWithCC": "Ievadiet segvārdu, lai izmantotu tērzēšanu un slēgtos subtitrus",
|
||||
"titleWithPolls": "Ierakstiet segvārdu, lai izmantotu tērzēšanu un aptaujas",
|
||||
"titleWithPollsAndCC": "Ievadiet segvārdu, lai izmantotu tērzēšanu, aptaujas un slēgtos subtitrus",
|
||||
"titleWithPollsAndCCAndFileSharing": "Ievadiet segvārdu, lai izmantotu tērzēšanu, aptaujas, slēgtos subtitrus un failus"
|
||||
},
|
||||
"noMessagesMessage": "Sapulcē pagaidām nav nevienas ziņas. Uzsāciet saraksti!",
|
||||
"privateNotice": "Privāta ziņa adresātam {{recipient}}",
|
||||
@@ -137,12 +148,12 @@
|
||||
"systemDisplayName": "Sistēma",
|
||||
"tabs": {
|
||||
"chat": "Tērzēšana",
|
||||
"closedCaptions": "Slēptie subtitri",
|
||||
"closedCaptions": "Slēgtie subtitri",
|
||||
"fileSharing": "Faili",
|
||||
"polls": "Aptaujas"
|
||||
},
|
||||
"title": "Tērzēšana",
|
||||
"titleWithCC": "Tērzēšana un Slēptie subtitri",
|
||||
"titleWithCC": "Tērzēšana un Slēgtie subtitri",
|
||||
"titleWithFeatures": "Tērzēšana un",
|
||||
"titleWithFileSharing": "Faili",
|
||||
"titleWithPolls": "Tērzēšana un Aptaujas",
|
||||
@@ -156,8 +167,8 @@
|
||||
"installExtensionText": "Uzstādīt spraudni Google kalendāra un Office 365 integrācijai"
|
||||
},
|
||||
"closedCaptionsTab": {
|
||||
"emptyState": "Slēpto subtitru saturs būs pieejams, tiklīdz moderators uzsāks to.",
|
||||
"startClosedCaptionsButton": "Uzsākt slēptos subtitrus"
|
||||
"emptyState": "Slēgto subtitru saturs būs pieejams, tiklīdz moderators uzsāks to.",
|
||||
"startClosedCaptionsButton": "Uzsākt slēgtos subtitrus"
|
||||
},
|
||||
"connectingOverlay": {
|
||||
"joiningRoom": "Notiek pieslēgšanās jūsu sapulcei…"
|
||||
@@ -280,7 +291,6 @@
|
||||
"Submit": "Iesniegt",
|
||||
"Understand": "Saprotu",
|
||||
"UnderstandAndUnmute": "Es saprotu, lūdzu, ieslēdziet skaņu.",
|
||||
"WaitForHostMsg": "Sapulce vēl nav sākusies, jo vēl nav ieradies neviens moderators. Lūdzu, autorizējieties, lai kļūtu par moderatoru. Pretējā gadījumā, lūdzu, uzgaidiet.",
|
||||
"WaitForHostNoAuthMsg": "Sapulce vēl nav sākusies, jo vēl nav ieradies neviens moderators. Lūdzu, uzgaidiet.",
|
||||
"WaitingForHostButton": "Gaidīt rīkotāju",
|
||||
"WaitingForHostTitle": "Gaida rīkotāju…",
|
||||
@@ -417,7 +427,7 @@
|
||||
"muteParticipantsVideoDialog": "Vai tiešām vēlaties izslēgt šī dalībnieka kameru? Jūs nevarēsiet to ieslēgt atpakaļ, taču dalībnieks pats to varēs izdarīt jebkurā laikā.",
|
||||
"muteParticipantsVideoDialogModerationOn": "Vai tiešām vēlaties izslēgt šī dalībnieka kameru? Ne Jūs, ne dalībnieks nevarēsiet to ieslēgt atpakaļ.",
|
||||
"muteParticipantsVideoTitle": "Vai izslēgt šī dalībnieka video?",
|
||||
"noDropboxToken": "Nav derīga Dropbox tokena",
|
||||
"noDropboxToken": "Nav derīgas Dropbox pilnvaras",
|
||||
"password": "Parole",
|
||||
"passwordLabel": "Dalībnieks ir aizslēdzis sapulci. Lūdzu, ievadiet $t(lockRoomPassword), lai pievienotos.",
|
||||
"passwordNotSupported": "Sapulces slēgšana ar $t(lockRoomPassword) netiek atbalstīta.",
|
||||
@@ -501,7 +511,7 @@
|
||||
"stopStreamingWarning": "Tiešām vēlaties beigt tiešraidi?",
|
||||
"streamKey": "Tiešraides atslēga",
|
||||
"thankYou": "Paldies, ka izmantojāt {{appName}}!",
|
||||
"token": "tokens",
|
||||
"token": "pilnvara",
|
||||
"tokenAuthFailed": "Atvainojiet, jums nav atļauts pievienoties šim zvanam.",
|
||||
"tokenAuthFailedReason": {
|
||||
"audInvalid": "Nederīga `aud` vērtība. Tai vajadzētu būt `jitsi`.",
|
||||
@@ -517,12 +527,13 @@
|
||||
"nbfFuture": "`nbf` vērtība ir nākotnē.",
|
||||
"nbfInvalid": "Nederīga `nbf` vērtība.",
|
||||
"payloadNotFound": "Trūkst satura.",
|
||||
"tokenExpired": "Token ir beidzies."
|
||||
"tokenExpired": "Pilnvara ir beigusies."
|
||||
},
|
||||
"tokenAuthFailedTitle": "Autentifikācijas kļūda",
|
||||
"tokenAuthFailedWithReasons": "Atvainojiet, jums nav atļauts pievienoties šim zvanam. Iespējamie iemesli: {{reason}}",
|
||||
"tokenAuthUnsupported": "Token URL netiek atbalstīts.",
|
||||
"tokenAuthUnsupported": "Pilnvaras URL nav atbalstīts.",
|
||||
"transcribing": "Notiek atšifrējuma izveide",
|
||||
"unauthenticatedAccessDisabled": "Šim zvanam nepieciešama autentifikācija. Lūdzu, piesakieties, lai turpinātu.",
|
||||
"unlockRoom": "Noņemt $t(lockRoomPassword)",
|
||||
"user": "Lietotājs",
|
||||
"userIdentifier": "Lietotājvārds",
|
||||
@@ -570,10 +581,12 @@
|
||||
"downloadStarted": "Sākta faila lejuplāde",
|
||||
"dragAndDrop": "Velciet un palaidiet failus šeit, vai jebkurā ekrāna vietā",
|
||||
"fileAlreadyUploaded": "Fails jau ir augšuplādēts šajā sanāksmē.",
|
||||
"fileRemovedByOther": "Jūsu fails '{{ fileName }}' tika noņemts",
|
||||
"fileTooLargeDescription": "Lūdzu, pārliecinieties, vai faila lielums nepārsniedz {{ maxFileSize }}.",
|
||||
"fileTooLargeTitle": "Izvēlētais fails ir pārāk liels",
|
||||
"fileUploadProgress": "Faila augšuplādes gaita",
|
||||
"fileUploadedSuccessfully": "Fails veiksmīgi augšuplādēts",
|
||||
"newFileNotification": "{{ participantName }} kopīgoja '{{ fileName }}'",
|
||||
"removeFile": "Noņemt",
|
||||
"removeFileSuccess": "Fails veiksmīgi noņemts",
|
||||
"uploadFailedDescription": "Lūdzu, mēģiniet vēlreiz.",
|
||||
@@ -748,7 +761,8 @@
|
||||
"notificationTitle": "Vestibils",
|
||||
"passwordJoinButton": "Pievienoties",
|
||||
"title": "Vestibils",
|
||||
"toggleLabel": "Iespējot vestibilu"
|
||||
"toggleLabel": "Iespējot vestibilu",
|
||||
"waitForModerator": "Konference vēl nav sākusies, jo vēl nav ieradušies moderatori. Ja vēlaties kļūt par moderatoru, lūdzu, piesakieties. Pretējā gadījumā, lūdzu, uzgaidiet."
|
||||
},
|
||||
"localRecording": {
|
||||
"clientState": {
|
||||
@@ -775,7 +789,7 @@
|
||||
"participant": "Dalībnieks",
|
||||
"participantStats": "Dalībnieku statistika",
|
||||
"selectTabTitle": "🎥 Lūdzu, atveriet šo cilni ierakstīšanai",
|
||||
"sessionToken": "Sessijas tokens",
|
||||
"sessionToken": "Sesijas Pilnvara",
|
||||
"start": "Sākt ierakstu",
|
||||
"stop": "Beigt ierakstu",
|
||||
"stopping": "Ierakstīšanas pārtraukšana",
|
||||
@@ -865,6 +879,7 @@
|
||||
"oldElectronClientDescription1": "Izskatās, ka jūs izmantojat vecu Jitsi Meet klienta versiju, kurai ir zināmas drošības ievainojamības. Lūdzu, atjauniniet uz ",
|
||||
"oldElectronClientDescription2": "jaunākā versija",
|
||||
"oldElectronClientDescription3": "tagad!",
|
||||
"openChat": "Atvērt tērzētavu",
|
||||
"participantWantsToJoin": "Vēlas pievienoties sapulcei",
|
||||
"participantsWantToJoin": "Vēlas pievienoties sapulcei",
|
||||
"passwordRemovedRemotely": "Kāds dalībnieks noņēma $t(lockRoomPasswordUppercase).",
|
||||
@@ -963,6 +978,9 @@
|
||||
"by": "Pēc {{ name }} iniciatīvas",
|
||||
"closeButton": "Slēgt aptauju",
|
||||
"create": {
|
||||
"accessibilityLabel": {
|
||||
"send": "Nosūtīt aptauju"
|
||||
},
|
||||
"addOption": "Pievienot opciju",
|
||||
"answerPlaceholder": "Opcija {{index}}",
|
||||
"cancel": "Atcelt",
|
||||
@@ -971,8 +989,7 @@
|
||||
"pollQuestion": "Aptaujas Jautājums",
|
||||
"questionPlaceholder": "Uzdod jautājumu",
|
||||
"removeOption": "Noņemt opciju",
|
||||
"save": "Saglabāt",
|
||||
"send": "Nosūtīt"
|
||||
"save": "Saglabāt"
|
||||
},
|
||||
"errors": {
|
||||
"notUniqueOption": "Iespējām jābūt unikālām"
|
||||
@@ -1091,7 +1108,7 @@
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
"authDropboxText": "Augšupielādēt uz Dropbox",
|
||||
"authDropboxText": "Augšuplādēt uz Dropbox",
|
||||
"availableSpace": "Pieejama vieta: {{spaceLeft}} MB (apmēram {{duration}} ieraksta minūtes)",
|
||||
"beta": "BETA",
|
||||
"busy": "Cenšamies nodrošināt ierakstam vairāk resursu. Lūdzu, pēc dažām minūtēm pamēģiniet vēlreiz.",
|
||||
@@ -1145,7 +1162,7 @@
|
||||
"title": "Ieraksts",
|
||||
"unavailable": "Hmm! {{serviceName}} pašlaik nav pieejams. Mēs strādājam pie problēmas risināšanas. Lūdzu, pamēģiniet vēlreiz vēlāk.",
|
||||
"unavailableTitle": "Ieraksts nav iespējams",
|
||||
"uploadToCloud": "Augšupielādēt mākonī"
|
||||
"uploadToCloud": "Augšuplādēt mākonī"
|
||||
},
|
||||
"screenshareDisplayName": "{{name}} ekrāns",
|
||||
"sectionList": {
|
||||
@@ -1300,7 +1317,7 @@
|
||||
"closeChat": "Aizvērt tērzēšanu",
|
||||
"closeMoreActions": "Aizvērt vairāk darbību izvēlni",
|
||||
"closeParticipantsPane": "Aizvērt dalībnieku paneli",
|
||||
"closedCaptions": "Slēptie subtitri",
|
||||
"closedCaptions": "Slēgtie subtitri",
|
||||
"collapse": "Sakļaut",
|
||||
"document": "Kopīgotais dokuments (iesl./izsl.)",
|
||||
"documentClose": "Aizvērt kopīgoto dokumentu",
|
||||
@@ -1378,7 +1395,21 @@
|
||||
"videomuteGUMPending": "Kameras pievienošana",
|
||||
"videounmute": "Ieslēgt kameru"
|
||||
},
|
||||
"addPeople": "Pievienot cilvēkus savai sesijai/zvanam",
|
||||
"addPeople": "Pievienot cilvēkus savam zvanam",
|
||||
"advancedAudioSettings": {
|
||||
"aec": {
|
||||
"label": "Akustiskās atbalss slāpēšana"
|
||||
},
|
||||
"agc": {
|
||||
"label": "Automātiska pastiprinājuma kontrole"
|
||||
},
|
||||
"ns": {
|
||||
"label": "Trokšņu slāpēšana"
|
||||
},
|
||||
"stereo": {
|
||||
"label": "Stereo"
|
||||
}
|
||||
},
|
||||
"audioOnlyOff": "Atspējot kanāla/trafika taupības režīmu",
|
||||
"audioOnlyOn": "Iespējot kanāla/trafika taupības režīmu",
|
||||
"audioRoute": "Izvēlēties audioierīci",
|
||||
@@ -1391,7 +1422,7 @@
|
||||
"closeChat": "Aizvērt tērzētavu",
|
||||
"closeParticipantsPane": "Aizvērt dalībnieku paneli",
|
||||
"closeReactionsMenu": "Aizvērt reakciju izvēlni",
|
||||
"closedCaptions": "Slēptie subtitri",
|
||||
"closedCaptions": "Slēgtie subtitri",
|
||||
"disableNoiseSuppression": "Atspējot trokšņu slāpēšanu",
|
||||
"disableReactionSounds": "Šai sapulcei varat atspējot reakcijas skaņas",
|
||||
"documentClose": "Aizvērt kopīgoto dokumentu",
|
||||
@@ -1406,6 +1437,7 @@
|
||||
"exitFullScreen": "Pilnekrāna režīms",
|
||||
"exitTileView": "Tuvplāna režīms",
|
||||
"feedback": "Atstāts atsauksmi",
|
||||
"fileSharing": "Failu kopīgošana",
|
||||
"giphy": "GIPHY izvēlne (rādīt/nerādīt)",
|
||||
"hangup": "Iziet no sapulces",
|
||||
"help": "Palīdzība",
|
||||
@@ -1441,17 +1473,19 @@
|
||||
"openReactionsMenu": "Atvērt reakciju izvēlni",
|
||||
"participants": "Dalībnieki",
|
||||
"pip": "Iesl. attēls attēlā (PIP) režīmu",
|
||||
"polls": "Aptaujas",
|
||||
"privateMessage": "Nosūtīt privātu ziņu",
|
||||
"profile": "Rediģēt profilu",
|
||||
"raiseHand": "Pacelt roku",
|
||||
"raiseYourHand": "Pacelt roku",
|
||||
"reactionBoo": "Nosūtīt būū reakciju",
|
||||
"reactionClap": "Nosūtīt aplausu reakciju",
|
||||
"reactionHeart": "Nosūtīt sirds reakciju",
|
||||
"reactionLaugh": "Nosūtīt smieklu reakciju",
|
||||
"reactionLike": "Nosūtīt īkšķi augšup reakciju",
|
||||
"reactionSilence": "Nosūtīt klusuma reakciju",
|
||||
"reactionSurprised": "Nosūtīt pārsteigts reakciju",
|
||||
"reactionBoo": "Sūtīt būū reakciju",
|
||||
"reactionClap": "Sūtīt aplausu reakciju",
|
||||
"reactionHeart": "Sūtīt sirds reakciju",
|
||||
"reactionLaugh": "Sūtīt smieklu reakciju",
|
||||
"reactionLike": "Sūtīt īkšķis augšup reakciju",
|
||||
"reactionLove": "Sūtīt mīlestības reakciju",
|
||||
"reactionSilence": "Sūtīt klusuma reakciju",
|
||||
"reactionSurprised": "Sūtīt pārsteiguma reakciju",
|
||||
"reactions": "Reakcijas",
|
||||
"security": "Drošības iespējas",
|
||||
"selectBackground": "Izvēlēties fonu",
|
||||
@@ -1484,7 +1518,7 @@
|
||||
"failed": "Atšifrējuma izveide neizdevās",
|
||||
"labelTooltip": "Šajā sapulcē notiek atšifrējuma izveide.",
|
||||
"labelTooltipExtra": "Turklāt vēlāk būs pieejams atšifrējums.",
|
||||
"openClosedCaptions": "Atvērt slēptos subtitrus",
|
||||
"openClosedCaptions": "Atvērt slēgtos subtitrus",
|
||||
"original": "Oriģināls",
|
||||
"sourceLanguageDesc": "Pašlaik sapulces valoda ir iestatīta uz <b>{{sourceLanguage}}</b>. <br/> Varat to mainīt no ",
|
||||
"sourceLanguageHere": "šeit",
|
||||
@@ -1582,7 +1616,7 @@
|
||||
"removeBackground": "Noņemt fonu",
|
||||
"slightBlur": "Viegli izplūdis",
|
||||
"title": "Virtuālie foni",
|
||||
"uploadedImage": "Augšupielādēts attēls {{index}}",
|
||||
"uploadedImage": "Augšuplādēts attēls {{index}}",
|
||||
"webAssemblyWarning": "WebAssembly netiek atbalstīts",
|
||||
"webAssemblyWarningDescription": "WebAssemb ir atspējots vai šī pārlūkprogramma to neatbalsta"
|
||||
},
|
||||
@@ -1601,6 +1635,8 @@
|
||||
"noMainParticipantsTitle": "Šī sapulce vēl nav sākusies.",
|
||||
"noVisitorLobby": "Jūs nevarat pievienoties, kamēr sapulcei ir iespējots vestibils.",
|
||||
"notAllowedPromotion": "Dalībniekam vispirms ir jāatļauj jūsu pieprasījums.",
|
||||
"requestToJoin": "Roka Pacelta",
|
||||
"requestToJoinDescription": "Jūsu pieprasījums tika nosūtīts moderatoriem. Uzgaidiet!",
|
||||
"title": "Jūs esat sapulces apmeklētājs"
|
||||
},
|
||||
"waitingMessage": "Jūs pievienosities sapulcei, tiklīdz tā sāksies!"
|
||||
|
||||
@@ -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!"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
@@ -340,6 +352,7 @@ function initCommands() {
|
||||
|
||||
APP.store.dispatch(setAssumedBandwidthBps(value));
|
||||
},
|
||||
|
||||
'set-blurred-background': blurType => {
|
||||
const tracks = APP.store.getState()['features/base/tracks'];
|
||||
const videoTrack = getLocalVideoTrack(tracks)?.jitsiTrack;
|
||||
@@ -777,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));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -803,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') {
|
||||
@@ -906,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 }) => {
|
||||
@@ -1241,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.
|
||||
@@ -1385,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.
|
||||
*
|
||||
@@ -2234,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.
|
||||
*
|
||||
|
||||
119
modules/API/external/external_api.js
vendored
119
modules/API/external/external_api.js
vendored
@@ -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.
|
||||
*
|
||||
|
||||
@@ -158,11 +158,10 @@ const VideoLayout = {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const state = APP.store.getState();
|
||||
const currentContainer = largeVideo.getCurrentContainer();
|
||||
const currentContainerType = largeVideo.getCurrentContainerType();
|
||||
const isOnLarge = this.isCurrentlyOnLarge(id);
|
||||
const state = APP.store.getState();
|
||||
const participant = getParticipantById(state, id);
|
||||
const videoTrack = getVideoTrackByParticipant(state, participant);
|
||||
const videoStream = videoTrack?.jitsiTrack;
|
||||
|
||||
3254
package-lock.json
generated
3254
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -28,7 +28,7 @@
|
||||
"@jitsi/js-utils": "2.6.7",
|
||||
"@jitsi/logger": "2.1.1",
|
||||
"@jitsi/rnnoise-wasm": "0.2.1",
|
||||
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
|
||||
"@matrix-org/olm": "3.2.15",
|
||||
"@microsoft/microsoft-graph-client": "3.0.1",
|
||||
"@mui/material": "5.12.1",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
@@ -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/v2109.0.0+cb9d000c/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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../base/config/middleware';
|
||||
import '../dynamic-branding/middleware';
|
||||
import '../gifs/middleware';
|
||||
import '../mobile/audio-mode/middleware';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
26
react/features/base/avatar/components/web/styles.ts
Normal file
26
react/features/base/avatar/components/web/styles.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -618,6 +624,10 @@ export interface IConfig {
|
||||
toolbarConfig?: {
|
||||
alwaysVisible?: boolean;
|
||||
autoHideWhileChatIsOpen?: boolean;
|
||||
/**
|
||||
* Background color for the main toolbar. Accepts any valid CSS color.
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
initialTimeout?: number;
|
||||
timeout?: number;
|
||||
};
|
||||
@@ -628,7 +638,6 @@ export interface IConfig {
|
||||
autoTranscribeOnRecord?: boolean;
|
||||
disableClosedCaptions?: boolean;
|
||||
enabled?: boolean;
|
||||
inviteJigasiOnBackendTranscribing?: boolean;
|
||||
preferredLanguage?: string;
|
||||
translationLanguages?: Array<string>;
|
||||
translationLanguagesHead?: Array<string>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1
react/features/base/config/middleware.native.ts
Normal file
1
react/features/base/config/middleware.native.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './middleware.any';
|
||||
48
react/features/base/config/middleware.web.ts
Normal file
48
react/features/base/config/middleware.web.ts
Normal 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;
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,13 +3,14 @@ import { jitsiLocalStorage } from '@jitsi/js-utils/jitsi-local-storage';
|
||||
// eslint-disable-next-line lines-around-comment
|
||||
// @ts-ignore
|
||||
import { safeJsonParse } from '@jitsi/js-utils/json';
|
||||
import { pick } from 'lodash-es';
|
||||
|
||||
import { browser } from '../lib-jitsi-meet';
|
||||
import { isEmbedded } from '../util/embedUtils';
|
||||
import { parseURLParams } from '../util/parseURLParams';
|
||||
|
||||
import logger from './logger';
|
||||
|
||||
import WHITELIST from './whitelist';
|
||||
|
||||
/**
|
||||
* Handles changes of the fake local storage.
|
||||
@@ -61,7 +62,7 @@ function setupJitsiLocalStorage() {
|
||||
|
||||
if (shouldUseHostPageLocalStorage(urlParams)) {
|
||||
try {
|
||||
const localStorageContent = safeJsonParse(urlParams['appData.localStorageContent']);
|
||||
let localStorageContent = safeJsonParse(urlParams['appData.localStorageContent']);
|
||||
|
||||
// We need to disable the local storage before setting the data in case the browser local storage doesn't
|
||||
// throw exception (in some cases when this happens the local storage may be cleared for every session.
|
||||
@@ -71,6 +72,10 @@ function setupJitsiLocalStorage() {
|
||||
jitsiLocalStorage.setLocalStorageDisabled(true);
|
||||
|
||||
if (typeof localStorageContent === 'object') {
|
||||
if (!isEmbedded()) {
|
||||
localStorageContent = pick(localStorageContent, WHITELIST);
|
||||
}
|
||||
|
||||
Object.keys(localStorageContent).forEach(key => {
|
||||
jitsiLocalStorage.setItem(key, localStorageContent[key]);
|
||||
});
|
||||
|
||||
11
react/features/base/jitsi-local-storage/whitelist.ts
Normal file
11
react/features/base/jitsi-local-storage/whitelist.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Keys of localStorage that are used by jibri.
|
||||
*/
|
||||
export default [
|
||||
'callStatsUserName',
|
||||
'displayname',
|
||||
'email',
|
||||
'xmpp_username_override',
|
||||
'xmpp_password_override',
|
||||
'xmpp_conference_password_override'
|
||||
];
|
||||
@@ -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}.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IReduxState, IStore } from '../../app/types';
|
||||
import { isTrackStreamingStatusActive } from '../../connection-indicator/functions';
|
||||
import { handleToggleVideoMuted } from '../../toolbox/actions.any';
|
||||
import { muteLocal } from '../../video-menu/actions.any';
|
||||
import { MEDIA_TYPE, VIDEO_TYPE } from '../media/constants';
|
||||
import { getParticipantById, isScreenShareParticipant } from '../participants/functions';
|
||||
import {
|
||||
@@ -78,3 +80,43 @@ export function isRemoteVideoReceived({ getState }: IStore, id: string): boolean
|
||||
|
||||
return Boolean(videoTrack && !videoTrack.muted && isTrackStreamingStatusActive(videoTrack));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes the local audio. Same as clicking the audio mute button.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @returns {Promise} Resolves when the action is complete.
|
||||
*/
|
||||
export function audioMute({ dispatch }: IStore) {
|
||||
return dispatch(muteLocal(true, MEDIA_TYPE.AUDIO));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmutes the local audio. Same as clicking the audio unmute button.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @returns {Promise} Resolves when the action is complete.
|
||||
*/
|
||||
export function audioUnmute({ dispatch }: IStore) {
|
||||
return dispatch(muteLocal(false, MEDIA_TYPE.AUDIO));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes the local video. Same as clicking the video mute button.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @returns {Promise} Resolves when the action is complete.
|
||||
*/
|
||||
export function videoMute({ dispatch }: IStore) {
|
||||
return dispatch(handleToggleVideoMuted(true, true, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmutes the local video. Same as clicking the video unmute button.
|
||||
*
|
||||
* @param {IStore} store - The redux store.
|
||||
* @returns {Promise} Resolves when the action is complete.
|
||||
*/
|
||||
export function videoUnmute({ dispatch }: IStore) {
|
||||
return dispatch(handleToggleVideoMuted(false, true, true));
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import { getJitsiMeetGlobalNS } from '../util/helpers';
|
||||
|
||||
import { setConnectionState } from './actions';
|
||||
import {
|
||||
audioMute,
|
||||
audioUnmute,
|
||||
getLocalCameraEncoding,
|
||||
getRemoteVideoType,
|
||||
isLargeVideoReceived,
|
||||
isRemoteVideoReceived,
|
||||
isTestModeEnabled
|
||||
isTestModeEnabled,
|
||||
videoMute,
|
||||
videoUnmute
|
||||
} from './functions';
|
||||
import logger from './logger';
|
||||
|
||||
@@ -85,10 +89,14 @@ function _bindTortureHelpers(store: IStore) {
|
||||
|
||||
// All torture helper methods go in here
|
||||
getJitsiMeetGlobalNS().testing = {
|
||||
audioMute: audioMute.bind(null, store),
|
||||
audioUnmute: audioUnmute.bind(null, store),
|
||||
getRemoteVideoType: getRemoteVideoType.bind(null, store),
|
||||
isLargeVideoReceived: isLargeVideoReceived.bind(null, store),
|
||||
getLocalCameraEncoding: getLocalCameraEncoding.bind(null, store),
|
||||
isRemoteVideoReceived: isRemoteVideoReceived.bind(null, store)
|
||||
isRemoteVideoReceived: isRemoteVideoReceived.bind(null, store),
|
||||
videoMute: videoMute.bind(null, store),
|
||||
videoUnmute: videoUnmute.bind(null, store),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { setScreenshareMuted } from '../media/actions';
|
||||
|
||||
import { addLocalTrack, replaceLocalTrack } from './actions.any';
|
||||
import { getLocalDesktopTrack, getTrackState } from './functions.native';
|
||||
import logger from './logger';
|
||||
|
||||
|
||||
export * from './actions.any';
|
||||
@@ -63,6 +64,6 @@ async function _startScreenSharing(dispatch: IStore['dispatch'], state: IReduxSt
|
||||
}, NOTIFICATION_TIMEOUT_TYPE.LONG));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log('ERROR creating screen-sharing stream ', error);
|
||||
logger.error('Error creating screen-sharing stream', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
isUserInteractionRequiredForUnmute,
|
||||
setTrackMuted
|
||||
} from './functions';
|
||||
import './subscriber';
|
||||
|
||||
/**
|
||||
* Middleware that captures LIB_DID_DISPOSE and LIB_DID_INIT actions and,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
52
react/features/base/tracks/subscriber.web.ts
Normal file
52
react/features/base/tracks/subscriber.web.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
@@ -298,8 +304,6 @@ const Chat = ({
|
||||
|
||||
// Disable text selection during resize
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
console.log('Chat resize: Mouse down', { clientX: e.clientX, initialWidth: _width });
|
||||
}, [ _width, dispatch ]);
|
||||
|
||||
/**
|
||||
@@ -315,8 +319,6 @@ const Chat = ({
|
||||
// Restore cursor and text selection
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
console.log('Chat resize: Mouse up');
|
||||
}
|
||||
}, [ isMouseDown, dispatch ]);
|
||||
|
||||
@@ -327,7 +329,6 @@ const Chat = ({
|
||||
* @returns {void}
|
||||
*/
|
||||
const onChatResize = useCallback(throttle((e: MouseEvent) => {
|
||||
// console.log('Chat resize: Mouse move', { clientX: e.clientX, isMouseDown, mousePosition, _width });
|
||||
if (isMouseDown && mousePosition !== null && dragChatWidth !== null) {
|
||||
// For chat panel resizing on the left edge:
|
||||
// - Dragging left (decreasing X coordinate) should make the panel wider
|
||||
@@ -572,6 +573,10 @@ const Chat = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (_reducedUI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
_isOpen ? <div
|
||||
className = { classes.container }
|
||||
@@ -628,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,
|
||||
@@ -638,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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import Button from '../../../base/ui/components/web/Button';
|
||||
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
|
||||
import { copyText } from '../../../base/util/copyText.web';
|
||||
import { handleLobbyChatInitialized, openChat } from '../../actions.web';
|
||||
import logger from '../../logger';
|
||||
|
||||
export interface IProps {
|
||||
className?: string;
|
||||
@@ -125,11 +126,11 @@ const MessageMenu = ({ message, participantId, isFromVisitor, isLobbyMessage, en
|
||||
setShowCopiedMessage(false);
|
||||
}, 2000);
|
||||
} else {
|
||||
console.error('Failed to copy text');
|
||||
logger.error('Failed to copy text');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error copying text:', error);
|
||||
.catch((error: Error) => {
|
||||
logger.error('Error copying text', error);
|
||||
});
|
||||
handleClose();
|
||||
}, [ message ]);
|
||||
|
||||
3
react/features/chat/logger.ts
Normal file
3
react/features/chat/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('app:chat');
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getVirtualScreenshareParticipantByOwnerId
|
||||
} from '../base/participants/functions';
|
||||
import { toState } from '../base/redux/functions';
|
||||
import { isStageFilmstripAvailable } from '../filmstrip/functions';
|
||||
import { getAutoPinSetting } from '../video-layout/functions';
|
||||
|
||||
import {
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
SET_LARGE_VIDEO_DIMENSIONS,
|
||||
UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION
|
||||
} from './actionTypes';
|
||||
import { shouldHideLargeVideo } from './functions';
|
||||
|
||||
/**
|
||||
* Action to select the participant to be displayed in LargeVideo based on the
|
||||
@@ -34,8 +34,12 @@ export function selectParticipantInLargeVideo(participant?: string) {
|
||||
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
|
||||
const state = getState();
|
||||
|
||||
// Skip large video updates when the large video container is hidden.
|
||||
if (shouldHideLargeVideo(state)) {
|
||||
if (isStageFilmstripAvailable(state, 2)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep Etherpad open.
|
||||
if (state['features/etherpad'].editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { IReduxState } from '../app/types';
|
||||
import { getParticipantById } from '../base/participants/functions';
|
||||
import { isStageFilmstripAvailable } from '../filmstrip/functions';
|
||||
import { shouldDisplayTileView } from '../video-layout/functions.any';
|
||||
|
||||
/**
|
||||
* Selector for the participant currently displaying on the large video.
|
||||
@@ -14,17 +12,3 @@ export function getLargeVideoParticipant(state: IReduxState) {
|
||||
|
||||
return getParticipantById(state, participantId ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the large video container should be hidden.
|
||||
* Large video is hidden in tile view, stage filmstrip mode (with multiple participants),
|
||||
* or when editing etherpad.
|
||||
*
|
||||
* @param {IReduxState} state - The Redux state.
|
||||
* @returns {boolean} True if large video should be hidden, false otherwise.
|
||||
*/
|
||||
export function shouldHideLargeVideo(state: IReduxState): boolean {
|
||||
return shouldDisplayTileView(state)
|
||||
|| isStageFilmstripAvailable(state, 2)
|
||||
|| Boolean(state['features/etherpad']?.editing);
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
|
||||
import { SELECT_LARGE_VIDEO_PARTICIPANT } from './actionTypes';
|
||||
import { selectParticipantInLargeVideo } from './actions.any';
|
||||
import { shouldHideLargeVideo } from './functions';
|
||||
|
||||
/**
|
||||
* Updates the large video when transitioning from a hidden state to visible state.
|
||||
* This ensures the large video is properly updated when exiting tile view, stage filmstrip,
|
||||
* whiteboard, or etherpad editing modes.
|
||||
*/
|
||||
StateListenerRegistry.register(
|
||||
/* selector */ state => shouldHideLargeVideo(state),
|
||||
/* listener */ (isHidden, { dispatch }) => {
|
||||
// When transitioning from hidden to visible state, select participant (because currently it is undefined).
|
||||
// Otherwise set it to undefined because we don't show the large video.
|
||||
if (!isHidden) {
|
||||
dispatch(selectParticipantInLargeVideo());
|
||||
} else {
|
||||
dispatch({
|
||||
type: SELECT_LARGE_VIDEO_PARTICIPANT,
|
||||
participantId: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
import './subscriber.any';
|
||||
|
||||
@@ -4,7 +4,6 @@ import StateListenerRegistry from '../base/redux/StateListenerRegistry';
|
||||
import { getVideoTrackByParticipant } from '../base/tracks/functions.web';
|
||||
|
||||
import { getLargeVideoParticipant } from './functions';
|
||||
import './subscriber.any';
|
||||
|
||||
/**
|
||||
* Updates the on stage participant video.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
4
react/features/pip/actionTypes.ts
Normal file
4
react/features/pip/actionTypes.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Action type to set Picture-in-Picture active state.
|
||||
*/
|
||||
export const SET_PIP_ACTIVE = 'SET_PIP_ACTIVE';
|
||||
193
react/features/pip/actions.ts
Normal file
193
react/features/pip/actions.ts
Normal 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());
|
||||
}
|
||||
};
|
||||
}
|
||||
24
react/features/pip/components/PiP.tsx
Normal file
24
react/features/pip/components/PiP.tsx
Normal 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;
|
||||
233
react/features/pip/components/PiPVideoElement.tsx
Normal file
233
react/features/pip/components/PiPVideoElement.tsx
Normal 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;
|
||||
31
react/features/pip/external-api.shared.ts
Normal file
31
react/features/pip/external-api.shared.ts
Normal 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();
|
||||
}
|
||||
461
react/features/pip/functions.ts
Normal file
461
react/features/pip/functions.ts
Normal 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
183
react/features/pip/hooks.ts
Normal 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
|
||||
};
|
||||
}
|
||||
3
react/features/pip/logger.ts
Normal file
3
react/features/pip/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../base/logging/functions';
|
||||
|
||||
export default getLogger('app:pip');
|
||||
1
react/features/pip/middleware.ts
Normal file
1
react/features/pip/middleware.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './subscriber';
|
||||
30
react/features/pip/reducer.ts
Normal file
30
react/features/pip/reducer.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
61
react/features/pip/subscriber.ts
Normal file
61
react/features/pip/subscriber.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
14
react/features/pip/types.ts
Normal file
14
react/features/pip/types.ts
Normal 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;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RESET_UNREAD_POLLS_COUNT,
|
||||
SAVE_POLL
|
||||
} from './actionTypes';
|
||||
import logger from './logger';
|
||||
import { IIncomingAnswerData, IPollData } from './types';
|
||||
|
||||
const INITIAL_STATE = {
|
||||
@@ -87,7 +88,7 @@ ReducerRegistry.register<IPollsState>(STORE_NAME, (state = INITIAL_STATE, action
|
||||
|
||||
// if the poll doesn't exist
|
||||
if (!(pollId in state.polls)) {
|
||||
console.warn('requested poll does not exist: pollId ', pollId);
|
||||
logger.warn('Requested poll does not exist', { pollId });
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user