From 4569d0135307bc7a79dd7b21b9de934bc6759a53 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 14 May 2025 12:09:53 +0200 Subject: [PATCH 1/5] Update dependency vite to v6.2.7 [SECURITY] (#3240) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 212 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 122 insertions(+), 90 deletions(-) diff --git a/yarn.lock b/yarn.lock index a42427b4..bf7244c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4007,142 +4007,142 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.37.0" +"@rollup/rollup-android-arm-eabi@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.40.2" conditions: os=android & cpu=arm languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-android-arm64@npm:4.37.0" +"@rollup/rollup-android-arm64@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-android-arm64@npm:4.40.2" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-darwin-arm64@npm:4.37.0" +"@rollup/rollup-darwin-arm64@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-darwin-arm64@npm:4.40.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-darwin-x64@npm:4.37.0" +"@rollup/rollup-darwin-x64@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-darwin-x64@npm:4.40.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.37.0" +"@rollup/rollup-freebsd-arm64@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.40.2" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-freebsd-x64@npm:4.37.0" +"@rollup/rollup-freebsd-x64@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-freebsd-x64@npm:4.40.2" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.37.0" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.40.2" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.37.0" +"@rollup/rollup-linux-arm-musleabihf@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.40.2" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.37.0" +"@rollup/rollup-linux-arm64-gnu@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.40.2" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.37.0" +"@rollup/rollup-linux-arm64-musl@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.40.2" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.37.0" +"@rollup/rollup-linux-loongarch64-gnu@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.40.2" conditions: os=linux & cpu=loong64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-powerpc64le-gnu@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.37.0" +"@rollup/rollup-linux-powerpc64le-gnu@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.40.2" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.37.0" +"@rollup/rollup-linux-riscv64-gnu@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.40.2" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.37.0" +"@rollup/rollup-linux-riscv64-musl@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.40.2" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.37.0" +"@rollup/rollup-linux-s390x-gnu@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.40.2" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.37.0" +"@rollup/rollup-linux-x64-gnu@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.40.2" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.37.0" +"@rollup/rollup-linux-x64-musl@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.40.2" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.37.0" +"@rollup/rollup-win32-arm64-msvc@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.40.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.37.0" +"@rollup/rollup-win32-ia32-msvc@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.40.2" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.37.0": - version: 4.37.0 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.37.0" +"@rollup/rollup-win32-x64-msvc@npm:4.40.2": + version: 4.40.2 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.40.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -4637,7 +4637,14 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:1.0.6, @types/estree@npm:^1.0.0": +"@types/estree@npm:1.0.7": + version: 1.0.7 + resolution: "@types/estree@npm:1.0.7" + checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" checksum: 10c0/cdfd751f6f9065442cd40957c07fd80361c962869aa853c1c2fd03e101af8b9389d8ff4955a43a6fcfa223dd387a089937f95be0f3eec21ca527039fd2d9859a @@ -7849,6 +7856,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.4": + version: 6.4.4 + resolution: "fdir@npm:6.4.4" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/6ccc33be16945ee7bc841e1b4178c0b4cf18d3804894cb482aa514651c962a162f96da7ffc6ebfaf0df311689fb70091b04dd6caffe28d56b9ebdc0e7ccadfdd + languageName: node + linkType: hard + "fflate@npm:^0.4.8": version: 0.4.8 resolution: "fflate@npm:0.4.8" @@ -11567,31 +11586,31 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.30.1": - version: 4.37.0 - resolution: "rollup@npm:4.37.0" +"rollup@npm:^4.34.9": + version: 4.40.2 + resolution: "rollup@npm:4.40.2" dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.37.0" - "@rollup/rollup-android-arm64": "npm:4.37.0" - "@rollup/rollup-darwin-arm64": "npm:4.37.0" - "@rollup/rollup-darwin-x64": "npm:4.37.0" - "@rollup/rollup-freebsd-arm64": "npm:4.37.0" - "@rollup/rollup-freebsd-x64": "npm:4.37.0" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.37.0" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.37.0" - "@rollup/rollup-linux-arm64-gnu": "npm:4.37.0" - "@rollup/rollup-linux-arm64-musl": "npm:4.37.0" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.37.0" - "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.37.0" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.37.0" - "@rollup/rollup-linux-riscv64-musl": "npm:4.37.0" - "@rollup/rollup-linux-s390x-gnu": "npm:4.37.0" - "@rollup/rollup-linux-x64-gnu": "npm:4.37.0" - "@rollup/rollup-linux-x64-musl": "npm:4.37.0" - "@rollup/rollup-win32-arm64-msvc": "npm:4.37.0" - "@rollup/rollup-win32-ia32-msvc": "npm:4.37.0" - "@rollup/rollup-win32-x64-msvc": "npm:4.37.0" - "@types/estree": "npm:1.0.6" + "@rollup/rollup-android-arm-eabi": "npm:4.40.2" + "@rollup/rollup-android-arm64": "npm:4.40.2" + "@rollup/rollup-darwin-arm64": "npm:4.40.2" + "@rollup/rollup-darwin-x64": "npm:4.40.2" + "@rollup/rollup-freebsd-arm64": "npm:4.40.2" + "@rollup/rollup-freebsd-x64": "npm:4.40.2" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.40.2" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.40.2" + "@rollup/rollup-linux-arm64-gnu": "npm:4.40.2" + "@rollup/rollup-linux-arm64-musl": "npm:4.40.2" + "@rollup/rollup-linux-loongarch64-gnu": "npm:4.40.2" + "@rollup/rollup-linux-powerpc64le-gnu": "npm:4.40.2" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.40.2" + "@rollup/rollup-linux-riscv64-musl": "npm:4.40.2" + "@rollup/rollup-linux-s390x-gnu": "npm:4.40.2" + "@rollup/rollup-linux-x64-gnu": "npm:4.40.2" + "@rollup/rollup-linux-x64-musl": "npm:4.40.2" + "@rollup/rollup-win32-arm64-msvc": "npm:4.40.2" + "@rollup/rollup-win32-ia32-msvc": "npm:4.40.2" + "@rollup/rollup-win32-x64-msvc": "npm:4.40.2" + "@types/estree": "npm:1.0.7" fsevents: "npm:~2.3.2" dependenciesMeta: "@rollup/rollup-android-arm-eabi": @@ -11638,7 +11657,7 @@ __metadata: optional: true bin: rollup: dist/bin/rollup - checksum: 10c0/2e00382e08938636edfe0a7547ea2eaa027205dc0b6ff85d8b82be0fbe55a4ef88a1995fee2a5059e33dbccf12d1376c236825353afb89c96298cc95c5160a46 + checksum: 10c0/cbe9b766891da74fbf7c3b50420bb75102e5c59afc0ea45751f7e43a581d2cd93367763f521f820b72e341cf1f6b9951fbdcd3be67a1b0aa774b754525a8b9c7 languageName: node linkType: hard @@ -12471,6 +12490,16 @@ __metadata: languageName: node linkType: hard +"tinyglobby@npm:^0.2.13": + version: 0.2.13 + resolution: "tinyglobby@npm:0.2.13" + dependencies: + fdir: "npm:^6.4.4" + picomatch: "npm:^4.0.2" + checksum: 10c0/ef07dfaa7b26936601d3f6d999f7928a4d1c6234c5eb36896bb88681947c0d459b7ebe797022400e555fe4b894db06e922b95d0ce60cb05fd827a0a66326b18c + languageName: node + linkType: hard + "tinypool@npm:^1.0.2": version: 1.0.2 resolution: "tinypool@npm:1.0.2" @@ -13179,13 +13208,16 @@ __metadata: linkType: hard "vite@npm:^5.0.0 || ^6.0.0, vite@npm:^6.0.0": - version: 6.2.6 - resolution: "vite@npm:6.2.6" + version: 6.3.5 + resolution: "vite@npm:6.3.5" dependencies: esbuild: "npm:^0.25.0" + fdir: "npm:^6.4.4" fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.2" postcss: "npm:^8.5.3" - rollup: "npm:^4.30.1" + rollup: "npm:^4.34.9" + tinyglobby: "npm:^0.2.13" peerDependencies: "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 jiti: ">=1.21.0" @@ -13226,7 +13258,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/68a2ed3e61bdd654c59b817b4f3203065241c66d1739faa707499130f3007bc3a666c7a8320a4198e275e62b5e4d34d9b78a6533f69e321d366e76f5093b2071 + checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c languageName: node linkType: hard From 86d80630c154446a0010d47a852ee13f0662cbb8 Mon Sep 17 00:00:00 2001 From: Valere Fedronic Date: Wed, 14 May 2025 18:41:22 +0200 Subject: [PATCH 2/5] Fix connection leaks: Ensure that any pending connection open are cancelled/undo when ActiveCall is unmounted (#3255) * Better logs for connection/component lifecycle * fix: `AudioCaptureOptions` was causing un-necessary effect render AudioCaptureOptions was a different object but with same internal values, use directly deviceId so that Object.is works properly * fix: Livekit openned connection leaks * review: rename to AbortHandles * review: rename variable --------- Co-authored-by: Timo --- src/livekit/useECConnectionState.test.tsx | 113 +++++++++++++++++++++- src/livekit/useECConnectionState.ts | 74 ++++++++++++-- src/livekit/useLivekit.ts | 4 +- src/room/GroupCallView.tsx | 7 ++ src/room/InCallView.tsx | 19 +++- src/room/LobbyView.tsx | 16 ++- src/utils/abortHandle.ts | 18 ++++ 7 files changed, 233 insertions(+), 18 deletions(-) create mode 100644 src/utils/abortHandle.ts diff --git a/src/livekit/useECConnectionState.test.tsx b/src/livekit/useECConnectionState.test.tsx index 5f2f6064..72324884 100644 --- a/src/livekit/useECConnectionState.test.tsx +++ b/src/livekit/useECConnectionState.test.tsx @@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details. */ import { type FC, useCallback, useState } from "react"; -import { test, vi } from "vitest"; +import { describe, expect, test, vi, vitest } from "vitest"; import { ConnectionError, ConnectionErrorReason, @@ -15,6 +15,7 @@ import { import userEvent from "@testing-library/user-event"; import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { defer, sleep } from "matrix-js-sdk/lib/utils"; import { useECConnectionState } from "./useECConnectionState"; import { type SFUConfig } from "./openIDSFU"; @@ -57,7 +58,7 @@ test.each<[string, ConnectionError]>([ () => setSfuConfig({ url: "URL", jwt: "JWT token" }), [], ); - useECConnectionState({}, false, mockRoom, sfuConfig); + useECConnectionState("default", false, mockRoom, sfuConfig); return ; }; @@ -73,3 +74,111 @@ test.each<[string, ConnectionError]>([ screen.getByText("Insufficient capacity"); }, ); + +describe("Leaking connection prevention", () => { + function createTestComponent(mockRoom: Room): FC { + const TestComponent: FC = () => { + const [sfuConfig, setSfuConfig] = useState( + undefined, + ); + const connect = useCallback( + () => setSfuConfig({ url: "URL", jwt: "JWT token" }), + [], + ); + useECConnectionState("default", false, mockRoom, sfuConfig); + return ; + }; + return TestComponent; + } + + test("Should cancel pending connections when the component is unmounted", async () => { + const connectCall = vi.fn(); + const pendingConnection = defer(); + // let pendingDisconnection = defer() + const disconnectMock = vi.fn(); + + const mockRoom = { + on: () => {}, + off: () => {}, + once: () => {}, + connect: async () => { + connectCall.call(undefined); + return await pendingConnection.promise; + }, + disconnect: disconnectMock, + localParticipant: { + getTrackPublication: () => {}, + createTracks: () => [], + }, + } as unknown as Room; + + const TestComponent = createTestComponent(mockRoom); + + const { unmount } = render(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Connect" })); + + expect(connectCall).toHaveBeenCalled(); + // unmount while the connection is pending + unmount(); + + // resolve the pending connection + pendingConnection.resolve(); + + await vitest.waitUntil( + () => { + return disconnectMock.mock.calls.length > 0; + }, + { + timeout: 1000, + interval: 100, + }, + ); + + // There should be some cleaning up to avoid leaking an open connection + expect(disconnectMock).toHaveBeenCalledTimes(1); + }); + + test("Should cancel about to open but not yet opened connection", async () => { + const createTracksCall = vi.fn(); + const pendingCreateTrack = defer(); + // let pendingDisconnection = defer() + const disconnectMock = vi.fn(); + const connectMock = vi.fn(); + + const mockRoom = { + on: () => {}, + off: () => {}, + once: () => {}, + connect: connectMock, + disconnect: disconnectMock, + localParticipant: { + getTrackPublication: () => {}, + createTracks: async () => { + createTracksCall.call(undefined); + await pendingCreateTrack.promise; + return []; + }, + }, + } as unknown as Room; + + const TestComponent = createTestComponent(mockRoom); + + const { unmount } = render(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Connect" })); + + expect(createTracksCall).toHaveBeenCalled(); + // unmount while createTracks is pending + unmount(); + + // resolve createTracks + pendingCreateTrack.resolve(); + + // Yield to the event loop to let the connection attempt finish + await sleep(100); + + // The operation should have been aborted before even calling connect. + expect(connectMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/livekit/useECConnectionState.ts b/src/livekit/useECConnectionState.ts index fa9a3038..3c7b91f8 100644 --- a/src/livekit/useECConnectionState.ts +++ b/src/livekit/useECConnectionState.ts @@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details. */ import { - type AudioCaptureOptions, ConnectionError, ConnectionState, type LocalTrack, @@ -25,6 +24,7 @@ import { InsufficientCapacityError, UnknownCallError, } from "../utils/errors.ts"; +import { AbortHandle } from "../utils/abortHandle.ts"; declare global { interface Window { @@ -59,7 +59,8 @@ async function doConnect( livekitRoom: Room, sfuConfig: SFUConfig, audioEnabled: boolean, - audioOptions: AudioCaptureOptions, + initialDeviceId: string | undefined, + abortHandle: AbortHandle, ): Promise { // Always create an audio track manually. // livekit (by default) keeps the mic track open when you mute, but if you start muted, @@ -82,19 +83,40 @@ async function doConnect( let preCreatedAudioTrack: LocalTrack | undefined; try { const audioTracks = await livekitRoom!.localParticipant.createTracks({ - audio: audioOptions, + audio: { deviceId: initialDeviceId }, }); + if (audioTracks.length < 1) { logger.info("Tried to pre-create local audio track but got no tracks"); } else { preCreatedAudioTrack = audioTracks[0]; } + // There was a yield point previously (awaiting for the track to be created) so we need to check + // if the operation was cancelled and stop connecting if needed. + if (abortHandle.isAborted()) { + logger.info( + "[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted", + ); + preCreatedAudioTrack?.stop(); + return; + } + logger.info("Pre-created microphone track"); } catch (e) { logger.error("Failed to pre-create microphone track", e); } - if (!audioEnabled) await preCreatedAudioTrack?.mute(); + if (!audioEnabled) { + await preCreatedAudioTrack?.mute(); + // There was a yield point. Check if the operation was cancelled and stop connecting. + if (abortHandle.isAborted()) { + logger.info( + "[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted", + ); + preCreatedAudioTrack?.stop(); + return; + } + } // check again having awaited for the track to create if ( @@ -107,9 +129,18 @@ async function doConnect( return; } - logger.info("Connecting & publishing"); + logger.info("[Lifecycle] Connecting & publishing"); try { await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []); + if (abortHandle.isAborted()) { + logger.info( + "[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect", + ); + livekitRoom?.disconnect().catch((err) => { + logger.error("Failed to disconnect from SFU", err); + }); + return; + } } catch (e) { preCreatedAudioTrack?.stop(); logger.debug("Stopped precreated audio tracks."); @@ -137,13 +168,16 @@ async function connectAndPublish( livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect); try { + logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`); await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, { // Due to stability issues on Firefox we are testing the effect of different // timeouts, and allow these values to be set through the console peerConnectionTimeout: window.peerConnectionTimeout ?? 45000, websocketTimeout: window.websocketTimeout ?? 45000, }); + logger.info(`[Lifecycle] ... connected to livekit room`); } catch (e) { + logger.error("[Lifecycle] Failed to connect", e); // LiveKit uses 503 to indicate that the server has hit its track limits. // https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171 // It also errors with a status code of 200 (yes, really) for room @@ -184,7 +218,7 @@ async function connectAndPublish( } export function useECConnectionState( - initialAudioOptions: AudioCaptureOptions, + initialDeviceId: string | undefined, initialAudioEnabled: boolean, livekitRoom?: Room, sfuConfig?: SFUConfig, @@ -247,6 +281,22 @@ export function useECConnectionState( const currentSFUConfig = useRef(Object.assign({}, sfuConfig)); + // Protection against potential leaks, where the component to be unmounted and there is + // still a pending doConnect promise. This would lead the user to still be in the call even + // if the component is unmounted. + const abortHandlesBag = useRef(new Set()); + + // This is a cleanup function that will be called when the component is about to be unmounted. + // It will cancel all abortHandles in the bag + useEffect(() => { + const bag = abortHandlesBag.current; + return (): void => { + bag.forEach((handle) => { + handle.abort(); + }); + }; + }, []); + // Id we are transitioning from a valid config to another valid one, we need // to explicitly switch focus useEffect(() => { @@ -273,11 +323,14 @@ export function useECConnectionState( // always capturing audio: it helps keep bluetooth headsets in the right mode and // mobile browsers to know we're doing a call. setIsInDoConnect(true); + const abortHandle = new AbortHandle(); + abortHandlesBag.current.add(abortHandle); doConnect( livekitRoom!, sfuConfig!, initialAudioEnabled, - initialAudioOptions, + initialDeviceId, + abortHandle, ) .catch((e) => { if (e instanceof ElementCallError) { @@ -286,14 +339,17 @@ export function useECConnectionState( setError(new UnknownCallError(e)); } else logger.error("Failed to connect to SFU", e); }) - .finally(() => setIsInDoConnect(false)); + .finally(() => { + abortHandlesBag.current.delete(abortHandle); + setIsInDoConnect(false); + }); } currentSFUConfig.current = Object.assign({}, sfuConfig); }, [ sfuConfig, livekitRoom, - initialAudioOptions, + initialDeviceId, initialAudioEnabled, doFocusSwitch, ]); diff --git a/src/livekit/useLivekit.ts b/src/livekit/useLivekit.ts index 10de9577..7cb32f5f 100644 --- a/src/livekit/useLivekit.ts +++ b/src/livekit/useLivekit.ts @@ -155,9 +155,7 @@ export function useLivekit( ); const connectionState = useECConnectionState( - { - deviceId: initialDevices.current.audioInput.selectedId, - }, + initialDevices.current.audioInput.selectedId, initialMuteStates.current.audio.enabled, room, sfuConfig, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 960b4a05..f1027b5c 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -121,6 +121,13 @@ export const GroupCallView: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + logger.info("[Lifecycle] GroupCallView Component mounted"); + return (): void => { + logger.info("[Lifecycle] GroupCallView Component unmounted"); + }; + }, []); + useEffect(() => { window.rtcSession = rtcSession; return (): void => { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 768ddfdd..0895684a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -132,10 +132,23 @@ export const ActiveCall: FC = (props) => { const [vm, setVm] = useState(null); useEffect(() => { + logger.info( + `[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`, + ); return (): void => { - livekitRoom?.disconnect().catch((e) => { - logger.error("Failed to disconnect from livekit room", e); - }); + logger.info( + `[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`, + ); + livekitRoom + ?.disconnect() + .then(() => { + logger.info( + `[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`, + ); + }) + .catch((e) => { + logger.error("[Lifecycle] Failed to disconnect from livekit room", e); + }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/room/LobbyView.tsx b/src/room/LobbyView.tsx index 0cabc645..72079783 100644 --- a/src/room/LobbyView.tsx +++ b/src/room/LobbyView.tsx @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, useCallback, useMemo, useState, type JSX } from "react"; +import { + type FC, + useCallback, + useMemo, + useState, + type JSX, + useEffect, +} from "react"; import { useTranslation } from "react-i18next"; import { type MatrixClient } from "matrix-js-sdk"; import { Button } from "@vector-im/compound-web"; @@ -72,6 +79,13 @@ export const LobbyView: FC = ({ onShareClick, waitingForInvite, }) => { + useEffect(() => { + logger.info("[Lifecycle] GroupCallView Component mounted"); + return (): void => { + logger.info("[Lifecycle] GroupCallView Component unmounted"); + }; + }, []); + const { t } = useTranslation(); usePageTitle(matrixInfo.roomName); diff --git a/src/utils/abortHandle.ts b/src/utils/abortHandle.ts new file mode 100644 index 00000000..f4bb2ef5 --- /dev/null +++ b/src/utils/abortHandle.ts @@ -0,0 +1,18 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +export class AbortHandle { + public constructor(private aborted = false) {} + + public abort(): void { + this.aborted = true; + } + + public isAborted(): boolean { + return this.aborted; + } +} From b5fe55aef2e30d2e5d8ee7de941ea5994b3963b6 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Thu, 15 May 2025 20:46:39 +0200 Subject: [PATCH 3/5] Add custom audio renderer for iPhone earpiece and only render joined participants (#3249) * Add custom audio renderer to only render joined participants & add ios earpice workaround fix left right to match chromium + safari (firefox is swapped) earpice as setting Simpler code and documentation The doc explains, what this class actually does and why it is so complicated. Signed-off-by: Timo K use only one audioContext, remove (non working) standby fallback * Add tests * use optional audio context and effect to initiate it + review --- locales/en/app.json | 2 + playwright/access.spec.ts | 2 +- src/UserMenu.tsx | 2 +- src/livekit/MatrixAudioRenderer.test.tsx | 104 +++++++++ src/livekit/MatrixAudioRenderer.tsx | 212 ++++++++++++++++++ src/livekit/MediaDevicesContext.tsx | 74 +++++- src/room/InCallView.test.tsx | 17 +- src/room/InCallView.tsx | 12 +- src/room/MuteStates.test.tsx | 1 + .../__snapshots__/InCallView.test.tsx.snap | 2 +- src/settings/DeveloperSettingsTab.tsx | 18 ++ src/settings/DeviceSelection.tsx | 30 ++- src/settings/SettingsModal.tsx | 8 +- src/settings/settings.ts | 8 + src/useAudioContext.test.tsx | 136 +++++++---- src/useAudioContext.tsx | 28 ++- src/utils/test.ts | 23 ++ 17 files changed, 588 insertions(+), 91 deletions(-) create mode 100644 src/livekit/MatrixAudioRenderer.test.tsx create mode 100644 src/livekit/MatrixAudioRenderer.tsx diff --git a/locales/en/app.json b/locales/en/app.json index 963a3f55..0b4c0599 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -61,6 +61,7 @@ "video": "Video" }, "developer_mode": { + "always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms", "crypto_version": "Crypto version: {{version}}", "debug_tile_layout_label": "Debug tile layout", "device_id": "Device ID: {{id}}", @@ -174,6 +175,7 @@ "camera_numbered": "Camera {{n}}", "default": "Default", "default_named": "Default <2>({{name}})", + "earpiece": "Earpiece", "microphone": "Microphone", "microphone_numbered": "Microphone {{n}}", "speaker": "Speaker", diff --git a/playwright/access.spec.ts b/playwright/access.spec.ts index 14a70873..da7ec364 100644 --- a/playwright/access.spec.ts +++ b/playwright/access.spec.ts @@ -49,7 +49,7 @@ test("Sign up a new account, then login, then logout", async ({ browser }) => { // logout await returningUserPage.getByTestId("usermenu_open").click(); - await returningUserPage.locator('[data-test-id="usermenu_logout"]').click(); + await returningUserPage.locator('[data-testid="usermenu_logout"]').click(); await expect( returningUserPage.getByRole("link", { name: "Log In" }), diff --git a/src/UserMenu.tsx b/src/UserMenu.tsx index 52cc4a5a..e431c328 100644 --- a/src/UserMenu.tsx +++ b/src/UserMenu.tsx @@ -119,7 +119,7 @@ export const UserMenu: FC = ({ key={key} Icon={Icon} label={label} - data-test-id={dataTestid} + data-testid={dataTestid} onSelect={() => onAction(key)} /> ))} diff --git a/src/livekit/MatrixAudioRenderer.test.tsx b/src/livekit/MatrixAudioRenderer.test.tsx new file mode 100644 index 00000000..637e02ed --- /dev/null +++ b/src/livekit/MatrixAudioRenderer.test.tsx @@ -0,0 +1,104 @@ +/* +Copyright 2023, 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; +import { + getTrackReferenceId, + type TrackReference, +} from "@livekit/components-core"; +import { type RemoteAudioTrack } from "livekit-client"; +import { type ReactNode } from "react"; +import { useTracks } from "@livekit/components-react"; + +import { testAudioContext } from "../useAudioContext.test"; +import * as MediaDevicesContext from "./MediaDevicesContext"; +import { MatrixAudioRenderer } from "./MatrixAudioRenderer"; +import { mockTrack } from "../utils/test"; + +export const TestAudioContextConstructor = vi.fn(() => testAudioContext); + +beforeEach(() => { + vi.stubGlobal("AudioContext", TestAudioContextConstructor); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +vi.mock("@livekit/components-react", async (importOriginal) => { + return { + ...(await importOriginal()), + AudioTrack: (props: { trackRef: TrackReference }): ReactNode => { + return ( + + ); + }, + useTracks: vi.fn(), + }; +}); + +const tracks = [mockTrack("test:123")]; +vi.mocked(useTracks).mockReturnValue(tracks); + +it("should render for member", () => { + const { container, queryAllByTestId } = render( + , + ); + expect(container).toBeTruthy(); + expect(queryAllByTestId("audio")).toHaveLength(1); +}); +it("should not render without member", () => { + const { container, queryAllByTestId } = render( + , + ); + expect(container).toBeTruthy(); + expect(queryAllByTestId("audio")).toHaveLength(0); +}); + +it("should not setup audioContext gain and pan if there is no need to.", () => { + render( + , + ); + const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; + + expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1); + expect(audioTrack.setAudioContext).toHaveBeenCalledWith(undefined); + expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledTimes(1); + expect(audioTrack.setWebAudioPlugins).toHaveBeenCalledWith([]); + + expect(testAudioContext.gain.gain.value).toEqual(1); + expect(testAudioContext.pan.pan.value).toEqual(0); +}); +it("should setup audioContext gain and pan", () => { + vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({ + pan: 1, + volume: 0.1, + }); + render( + , + ); + + const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; + expect(audioTrack.setAudioContext).toHaveBeenCalled(); + expect(audioTrack.setWebAudioPlugins).toHaveBeenCalled(); + + expect(testAudioContext.gain.gain.value).toEqual(0.1); + expect(testAudioContext.pan.pan.value).toEqual(1); +}); diff --git a/src/livekit/MatrixAudioRenderer.tsx b/src/livekit/MatrixAudioRenderer.tsx new file mode 100644 index 00000000..18f09106 --- /dev/null +++ b/src/livekit/MatrixAudioRenderer.tsx @@ -0,0 +1,212 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { getTrackReferenceId } from "@livekit/components-core"; +import { type RemoteAudioTrack, Track } from "livekit-client"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + useTracks, + AudioTrack, + type AudioTrackProps, +} from "@livekit/components-react"; +import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc"; +import { logger } from "matrix-js-sdk/lib/logger"; + +import { useEarpieceAudioConfig } from "./MediaDevicesContext"; +import { useReactiveState } from "../useReactiveState"; + +export interface MatrixAudioRendererProps { + /** + * The list of participants to render audio for. + * This list needs to be composed based on the matrixRTC members so that we do not play audio from users + * that are not expected to be in the rtc session. + */ + members: CallMembership[]; + /** + * If set to `true`, mutes all audio tracks rendered by the component. + * @remarks + * If set to `true`, the server will stop sending audio track data to the client. + */ + muted?: boolean; +} + +/** + * The `MatrixAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app. + * It takes care of handling remote participants’ audio tracks and makes sure that microphones and screen share are audible. + * + * It also takes care of the earpiece audio configuration for iOS devices. + * This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio. + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export function MatrixAudioRenderer({ + members, + muted, +}: MatrixAudioRendererProps): ReactNode { + const validIdentities = useMemo( + () => + new Set(members?.map((member) => `${member.sender}:${member.deviceId}`)), + [members], + ); + + const loggedInvalidIdentities = useRef(new Set()); + /** + * Log an invalid livekit track identity. + * A invalid identity is one that does not match any of the matrix rtc members. + * + * @param identity The identity of the track that is invalid + * @param validIdentities The list of valid identities + */ + const logInvalid = (identity: string, validIdentities: Set): void => { + if (loggedInvalidIdentities.current.has(identity)) return; + logger.warn( + `Audio track ${identity} has no matching matrix call member`, + `current members: ${Array.from(validIdentities.values())}`, + `track will not get rendered`, + ); + loggedInvalidIdentities.current.add(identity); + }; + + const tracks = useTracks( + [ + Track.Source.Microphone, + Track.Source.ScreenShareAudio, + Track.Source.Unknown, + ], + { + updateOnlyOn: [], + onlySubscribed: true, + }, + ).filter((ref) => { + const isValid = validIdentities?.has(ref.participant.identity); + if (!isValid && !ref.participant.isLocal) + logInvalid(ref.participant.identity, validIdentities); + return ( + !ref.participant.isLocal && + ref.publication.kind === Track.Kind.Audio && + isValid + ); + }); + + // This component is also (in addition to the "only play audio for connected members" logic above) + // responsible for mimicking earpiece audio on iPhones. + // The Safari audio devices enumeration does not expose an earpiece audio device. + // We alternatively use the audioContext pan node to only use one of the stereo channels. + + // This component does get additionally complicated because of a Safari bug. + // (see: https://bugs.webkit.org/show_bug.cgi?id=251532 + // and the related issues: https://bugs.webkit.org/show_bug.cgi?id=237878 + // and https://bugs.webkit.org/show_bug.cgi?id=231105) + // + // AudioContext gets stopped if the webview gets moved into the background. + // Once the phone is in standby audio playback will stop. + // So we can only use the pan trick only works is the phone is not in standby. + // If earpiece mode is not used we do not use audioContext to allow standby playback. + // shouldUseAudioContext is set to false if stereoPan === 0 to allow standby bluetooth playback. + + const { pan: stereoPan, volume: volumeFactor } = useEarpieceAudioConfig(); + const shouldUseAudioContext = stereoPan !== 0; + + // initialize the potentially used audio context. + const [audioContext, setAudioContext] = useState( + undefined, + ); + useEffect(() => { + const ctx = new AudioContext(); + setAudioContext(ctx); + return (): void => { + void ctx.close(); + }; + }, []); + const audioNodes = useMemo( + () => ({ + gain: audioContext?.createGain(), + pan: audioContext?.createStereoPanner(), + }), + [audioContext], + ); + + // Simple effects to update the gain and pan node based on the props + useEffect(() => { + if (audioNodes.pan) audioNodes.pan.pan.value = stereoPan; + }, [audioNodes.pan, stereoPan]); + useEffect(() => { + if (audioNodes.gain) audioNodes.gain.gain.value = volumeFactor; + }, [audioNodes.gain, volumeFactor]); + + return ( + // We add all audio elements into one
for the browser developer tool experience/tidyness. +
+ {tracks.map((trackRef) => ( + + ))} +
+ ); +} + +interface StereoPanAudioTrackProps { + muted?: boolean; + audioContext?: AudioContext; + audioNodes: { + gain?: GainNode; + pan?: StereoPannerNode; + }; +} + +/** + * This wraps `livekit.AudioTrack` to allow adding audio nodes to a track. + * It main purpose is to remount the AudioTrack component when switching from + * audiooContext to normal audio playback. + * As of now the AudioTrack component does not support adding audio nodes while being mounted. + * @param param0 + * @returns + */ +function AudioTrackWithAudioNodes({ + trackRef, + muted, + audioContext, + audioNodes, + ...props +}: StereoPanAudioTrackProps & + AudioTrackProps & + React.RefAttributes): ReactNode { + // This is used to unmount/remount the AudioTrack component. + // Mounting needs to happen after the audioContext is set. + // (adding the audio context when already mounted did not work outside strict mode) + const [trackReady, setTrackReady] = useReactiveState( + () => false, + // We only want the track to reset once both (audioNodes and audioContext) are set. + // for unsetting the audioContext its enough if one of the the is undefined. + [audioContext && audioNodes], + ); + + useEffect(() => { + if (!trackRef || trackReady) return; + const track = trackRef.publication.track as RemoteAudioTrack; + const useContext = audioContext && audioNodes.gain && audioNodes.pan; + track.setAudioContext(useContext ? audioContext : undefined); + track.setWebAudioPlugins( + useContext ? [audioNodes.gain!, audioNodes.pan!] : [], + ); + setTrackReady(true); + }, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]); + + return ( + trackReady && + ); +} diff --git a/src/livekit/MediaDevicesContext.tsx b/src/livekit/MediaDevicesContext.tsx index c2fc63e5..7d82032a 100644 --- a/src/livekit/MediaDevicesContext.tsx +++ b/src/livekit/MediaDevicesContext.tsx @@ -26,12 +26,16 @@ import { audioInput as audioInputSetting, audioOutput as audioOutputSetting, videoInput as videoInputSetting, + alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, type Setting, } from "../settings/settings"; +export const EARPIECE_CONFIG_ID = "earpiece-id"; + export type DeviceLabel = | { type: "name"; name: string } | { type: "number"; number: number } + | { type: "earpiece" } | { type: "default"; name: string | null }; export interface MediaDevice { @@ -40,6 +44,11 @@ export interface MediaDevice { */ available: Map; selectedId: string | undefined; + /** + * An additional device configuration that makes us use only one channel of the + * output device and a reduced volume. + */ + useAsEarpiece: boolean | undefined; /** * The group ID of the selected device. */ @@ -65,6 +74,7 @@ function useMediaDevice( ): MediaDevice { // Make sure we don't needlessly reset to a device observer without names, // once permissions are already given + const [alwaysShowIphoneEarpice] = useSetting(alwaysShowIphoneEarpieceSetting); const hasRequestedPermissions = useRef(false); const requestPermissions = usingNames || hasRequestedPermissions.current; hasRequestedPermissions.current ||= usingNames; @@ -102,27 +112,39 @@ function useMediaDevice( // Create a virtual default audio output for browsers that don't have one. // Its device ID must be the empty string because that's what setSinkId // recognizes. + // We also create this if we do not have any available devices, so that + // we can use the default or the earpiece. + const showEarpiece = + navigator.userAgent.match("iPhone") || alwaysShowIphoneEarpice; if ( kind === "audiooutput" && - available.size && !available.has("") && - !available.has("default") + !available.has("default") && + (available.size || showEarpiece) ) available = new Map([ ["", { type: "default", name: availableRaw[0]?.label || null }], ...available, ]); + if (kind === "audiooutput" && showEarpiece) + // On IPhones we have to create a virtual earpiece device, because + // the earpiece is not available as a device ID. + available = new Map([ + ...available, + [EARPIECE_CONFIG_ID, { type: "earpiece" }], + ]); // Note: creating virtual default input devices would be another problem // entirely, because requesting a media stream from deviceId "" won't // automatically track the default device. return available; }), ), - [kind, deviceObserver$], + [alwaysShowIphoneEarpice, deviceObserver$, kind], ), ); - const [preferredId, select] = useSetting(setting); + const [preferredId, setPreferredId] = useSetting(setting); + const [asEarpice, setAsEarpiece] = useState(false); const selectedId = useMemo(() => { if (available.size) { // If the preferred device is available, use it. Or if every available @@ -138,6 +160,7 @@ function useMediaDevice( } return undefined; }, [available, preferredId]); + const selectedGroupId = useObservableEagerState( useMemo( () => @@ -151,14 +174,27 @@ function useMediaDevice( ), ); + const select = useCallback( + (id: string) => { + if (id === EARPIECE_CONFIG_ID) { + setAsEarpiece(true); + } else { + setAsEarpiece(false); + setPreferredId(id); + } + }, + [setPreferredId], + ); + return useMemo( () => ({ available, selectedId, + useAsEarpiece: asEarpice, selectedGroupId, select, }), - [available, selectedId, selectedGroupId, select], + [available, selectedId, asEarpice, selectedGroupId, select], ); } @@ -167,6 +203,7 @@ export const deviceStub: MediaDevice = { selectedId: undefined, selectedGroupId: undefined, select: () => {}, + useAsEarpiece: false, }; export const devicesStub: MediaDevices = { audioInput: deviceStub, @@ -255,3 +292,30 @@ export const useMediaDeviceNames = ( return context.stopUsingDeviceNames; } }, [context, enabled]); + +/** + * A convenience hook to get the audio node configuration for the earpiece. + * It will check the `useAsEarpiece` of the `audioOutput` device and return + * the appropriate pan and volume values. + * + * @returns pan and volume values for the earpiece audio node configuration. + */ +export const useEarpieceAudioConfig = (): { + pan: number; + volume: number; +} => { + const { audioOutput } = useMediaDevices(); + // We use only the right speaker (pan = 1) for the earpiece. + // This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone) + const pan = useMemo( + () => (audioOutput.useAsEarpiece ? 1 : 0), + [audioOutput.useAsEarpiece], + ); + // We also do lower the volume by a factor of 10 to optimize for the usecase where + // a user is holding the phone to their ear. + const volume = useMemo( + () => (audioOutput.useAsEarpiece ? 0.1 : 1), + [audioOutput.useAsEarpiece], + ); + return { pan, volume }; +}; diff --git a/src/room/InCallView.test.tsx b/src/room/InCallView.test.tsx index 4d02160c..59ca7696 100644 --- a/src/room/InCallView.test.tsx +++ b/src/room/InCallView.test.tsx @@ -21,11 +21,7 @@ import { ConnectionState, type LocalParticipant } from "livekit-client"; import { of } from "rxjs"; import { BrowserRouter } from "react-router-dom"; import { TooltipProvider } from "@vector-im/compound-web"; -import { - RoomAudioRenderer, - RoomContext, - useLocalParticipant, -} from "@livekit/components-react"; +import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { type MuteStates } from "./MuteStates"; @@ -48,6 +44,7 @@ import { } from "../settings/settings"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; +import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer"; // vi.hoisted(() => { // localStorage = {} as unknown as Storage; @@ -65,6 +62,7 @@ vi.mock("../tile/GridTile"); vi.mock("../tile/SpotlightTile"); vi.mock("@livekit/components-react"); vi.mock("../e2ee/sharedKeyManagement"); +vi.mock("../livekit/MatrixAudioRenderer"); vi.mock("react-use-measure", () => ({ default: (): [() => void, object] => [(): void => {}, {}], })); @@ -81,13 +79,15 @@ const roomMembers = new Map([carol].map((p) => [p.userId, p])); const roomId = "!foo:bar"; let useRoomEncryptionSystemMock: MockedFunction; + beforeEach(() => { vi.clearAllMocks(); - // RoomAudioRenderer is tested separately. + + // MatrixAudioRenderer is tested separately. ( - RoomAudioRenderer as MockedFunction + MatrixAudioRenderer as MockedFunction ).mockImplementation((_props) => { - return
mocked: RoomAudioRenderer
; + return
mocked: MatrixAudioRenderer
; }); ( useLocalParticipant as MockedFunction @@ -98,7 +98,6 @@ beforeEach(() => { localParticipant: localRtcMember as unknown as LocalParticipant, }) as unknown as ReturnType, ); - useRoomEncryptionSystemMock = useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock; useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE }); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 0895684a..829d9c68 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - RoomAudioRenderer, - RoomContext, - useLocalParticipant, -} from "@livekit/components-react"; +import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import { Text } from "@vector-im/compound-web"; import { ConnectionState, type Room } from "livekit-client"; import { type MatrixClient } from "matrix-js-sdk"; @@ -107,6 +103,7 @@ import { import { ReactionsReader } from "../reactions/ReactionsReader"; import { ConnectionLostError } from "../utils/errors.ts"; import { useTypedEventEmitter } from "../useEvents.ts"; +import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -726,7 +723,10 @@ export const InCallView: FC = ({ ) } - + {renderContent()} diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx index 4a375c8f..eb066603 100644 --- a/src/room/MuteStates.test.tsx +++ b/src/room/MuteStates.test.tsx @@ -79,6 +79,7 @@ function mockDevices(available: Map): MediaDevice { selectedId: "", selectedGroupId: "", select: (): void => {}, + useAsEarpiece: false, }; } diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index 427973b6..98edb8a4 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -9,7 +9,7 @@ exports[`InCallView > rendering > renders 1`] = ` class="header filler" />
- mocked: RoomAudioRenderer + mocked: MatrixAudioRenderer
= ({ client, livekitRoom }) => { useNewMembershipManagerSetting, ); + const [alwaysShowIphoneEarpiece, setAlwaysShowIphoneEarpiece] = useSetting( + alwaysShowIphoneEarpieceSetting, + ); const [ useExperimentalToDeviceTransport, setUseExperimentalToDeviceTransport, @@ -192,6 +196,20 @@ export const DeveloperSettingsTab: FC = ({ client, livekitRoom }) => { [setMuteAllAudio], )} /> + {" "} + + ): void => { + setAlwaysShowIphoneEarpiece(event.target.checked); + }, + [setAlwaysShowIphoneEarpiece], + )} + />{" "} {livekitRoom ? ( <> diff --git a/src/settings/DeviceSelection.tsx b/src/settings/DeviceSelection.tsx index 0bdabbe7..396b1235 100644 --- a/src/settings/DeviceSelection.tsx +++ b/src/settings/DeviceSelection.tsx @@ -22,17 +22,20 @@ import { } from "@vector-im/compound-web"; import { Trans, useTranslation } from "react-i18next"; -import { type MediaDevice } from "../livekit/MediaDevicesContext"; +import { + EARPIECE_CONFIG_ID, + type MediaDevice, +} from "../livekit/MediaDevicesContext"; import styles from "./DeviceSelection.module.css"; interface Props { - devices: MediaDevice; + device: MediaDevice; title: string; numberedLabel: (number: number) => string; } export const DeviceSelection: FC = ({ - devices, + device, title, numberedLabel, }) => { @@ -40,12 +43,13 @@ export const DeviceSelection: FC = ({ const groupId = useId(); const onChange = useCallback( (e: ChangeEvent) => { - devices.select(e.target.value); + device.select(e.target.value); }, - [devices], + [device], ); - if (devices.available.size == 0) return null; + // There is no need to show the menu if there is no choice that can be made. + if (device.available.size <= 1) return null; return (
@@ -60,7 +64,7 @@ export const DeviceSelection: FC = ({
- {[...devices.available].map(([id, label]) => { + {[...device.available].map(([id, label]) => { let labelText: ReactNode; switch (label.type) { case "name": @@ -85,6 +89,16 @@ export const DeviceSelection: FC = ({ ); break; + case "earpiece": + labelText = t("settings.devices.earpiece"); + break; + } + + let isSelected = false; + if (device.useAsEarpiece) { + isSelected = id === EARPIECE_CONFIG_ID; + } else { + isSelected = id === device.selectedId; } return ( @@ -93,7 +107,7 @@ export const DeviceSelection: FC = ({ name={groupId} control={ diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index b0a4b79e..1c97a87d 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -98,7 +98,6 @@ export const SettingsModal: FC = ({ useMediaDeviceNames(devices, open); const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting); const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume); - const [showDeveloperSettingsTab] = useSetting(developerMode); const { available: isRageshakeAvailable } = useSubmitRageshake(); @@ -110,17 +109,18 @@ export const SettingsModal: FC = ({ <>
t("settings.devices.microphone_numbered", { n }) } /> t("settings.devices.speaker_numbered", { n })} /> +

{t("settings.audio_tab.effect_volume_description")}

@@ -146,7 +146,7 @@ export const SettingsModal: FC = ({ <> t("settings.devices.camera_numbered", { n })} /> diff --git a/src/settings/settings.ts b/src/settings/settings.ts index f63148ef..0c7b9191 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -44,6 +44,9 @@ export class Setting { this._value$.next(value); localStorage.setItem(this.key, JSON.stringify(value)); }; + public readonly getValue = (): T => { + return this._value$.getValue(); + }; } /** @@ -128,3 +131,8 @@ export const useExperimentalToDeviceTransport = new Setting( export const muteAllAudio = new Setting("mute-all-audio", false); export const alwaysShowSelf = new Setting("always-show-self", true); + +export const alwaysShowIphoneEarpiece = new Setting( + "always-show-iphone-earpice", + false, +); diff --git a/src/useAudioContext.test.tsx b/src/useAudioContext.test.tsx index 92d3a947..dd3c3b0c 100644 --- a/src/useAudioContext.test.tsx +++ b/src/useAudioContext.test.tsx @@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { expect, test, vitest, afterEach } from "vitest"; +import { expect, vi, afterEach, beforeEach, test } from "vitest"; import { type FC } from "react"; import { render } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import userEvent, { type UserEvent } from "@testing-library/user-event"; import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext"; import { useAudioContext } from "./useAudioContext"; @@ -39,61 +39,73 @@ const TestComponent: FC = () => { ); }; -class MockAudioContext { - public static testContext: MockAudioContext; - - public constructor() { - MockAudioContext.testContext = this; - } - - public gain = vitest.mocked( - { - connect: () => {}, - gain: { - setValueAtTime: vitest.fn(), - }, +const gainNode = vi.mocked( + { + connect: (node: AudioNode) => node, + gain: { + setValueAtTime: vi.fn(), + value: 1, }, - true, - ); - - public setSinkId = vitest.fn().mockResolvedValue(undefined); - public decodeAudioData = vitest.fn().mockReturnValue(1); - public createBufferSource = vitest.fn().mockReturnValue( - vitest.mocked({ + }, + true, +); +const panNode = vi.mocked( + { + connect: (node: AudioNode) => node, + pan: { + setValueAtTime: vi.fn(), + value: 0, + }, + }, + true, +); +/** + * A shared audio context test instance. + * It can also be used to mock the `AudioContext` constructor in tests: + * `vi.stubGlobal("AudioContext", () => testAudioContext);` + */ +export const testAudioContext = { + gain: gainNode, + pan: panNode, + setSinkId: vi.fn().mockResolvedValue(undefined), + decodeAudioData: vi.fn().mockReturnValue(1), + createBufferSource: vi.fn().mockReturnValue( + vi.mocked({ connect: (v: unknown) => v, start: () => {}, addEventListener: (_name: string, cb: () => void) => cb(), }), - ); - public createGain = vitest.fn().mockReturnValue(this.gain); - public close = vitest.fn().mockResolvedValue(undefined); -} + ), + createGain: vi.fn().mockReturnValue(gainNode), + createStereoPanner: vi.fn().mockReturnValue(panNode), + close: vi.fn().mockResolvedValue(undefined), +}; +export const TestAudioContextConstructor = vi.fn(() => testAudioContext); + +let user: UserEvent; +beforeEach(() => { + vi.stubGlobal("AudioContext", TestAudioContextConstructor); + user = userEvent.setup(); +}); afterEach(() => { - vitest.unstubAllGlobals(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); }); test("can play a single sound", async () => { - const user = userEvent.setup(); - vitest.stubGlobal("AudioContext", MockAudioContext); const { findByText } = render(); await user.click(await findByText("Valid sound")); - expect( - MockAudioContext.testContext.createBufferSource, - ).toHaveBeenCalledOnce(); + expect(testAudioContext.createBufferSource).toHaveBeenCalledOnce(); }); + test("will ignore sounds that are not registered", async () => { - const user = userEvent.setup(); - vitest.stubGlobal("AudioContext", MockAudioContext); const { findByText } = render(); await user.click(await findByText("Invalid sound")); - expect( - MockAudioContext.testContext.createBufferSource, - ).not.toHaveBeenCalled(); + expect(testAudioContext.createBufferSource).not.toHaveBeenCalled(); }); test("will use the correct device", () => { - vitest.stubGlobal("AudioContext", MockAudioContext); render( { selectedGroupId: "", available: new Map(), select: () => {}, + useAsEarpiece: false, }, videoInput: deviceStub, startUsingDeviceNames: () => {}, @@ -112,21 +125,46 @@ test("will use the correct device", () => { , ); - expect( - MockAudioContext.testContext.createBufferSource, - ).not.toHaveBeenCalled(); - expect(MockAudioContext.testContext.setSinkId).toHaveBeenCalledWith( - "chosen-device", - ); + expect(testAudioContext.createBufferSource).not.toHaveBeenCalled(); + expect(testAudioContext.setSinkId).toHaveBeenCalledWith("chosen-device"); }); test("will use the correct volume level", async () => { - const user = userEvent.setup(); - vitest.stubGlobal("AudioContext", MockAudioContext); soundEffectVolumeSetting.setValue(0.33); const { findByText } = render(); await user.click(await findByText("Valid sound")); - expect( - MockAudioContext.testContext.gain.gain.setValueAtTime, - ).toHaveBeenCalledWith(0.33, 0); + expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith( + 0.33, + 0, + ); + expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(0, 0); +}); + +test("will use the pan if earpice is selected", async () => { + const { findByText } = render( + {}, + useAsEarpiece: true, + }, + videoInput: deviceStub, + startUsingDeviceNames: () => {}, + stopUsingDeviceNames: () => {}, + }} + > + + , + ); + await user.click(await findByText("Valid sound")); + expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(1, 0); + + expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith( + soundEffectVolumeSetting.getValue() * 0.1, + 0, + ); }); diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index da94f387..5a689fdf 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -12,7 +12,10 @@ import { soundEffectVolume as soundEffectVolumeSetting, useSetting, } from "./settings/settings"; -import { useMediaDevices } from "./livekit/MediaDevicesContext"; +import { + useEarpieceAudioConfig, + useMediaDevices, +} from "./livekit/MediaDevicesContext"; import { type PrefetchedSounds } from "./soundUtils"; /** @@ -28,12 +31,15 @@ async function playSound( ctx: AudioContext, buffer: AudioBuffer, volume: number, + stereoPan: number, ): Promise { const gain = ctx.createGain(); gain.gain.setValueAtTime(volume, 0); + const pan = ctx.createStereoPanner(); + pan.pan.setValueAtTime(stereoPan, 0); const src = ctx.createBufferSource(); src.buffer = buffer; - src.connect(gain).connect(ctx.destination); + src.connect(gain).connect(pan).connect(ctx.destination); const p = new Promise((r) => src.addEventListener("ended", () => r())); src.start(); return p; @@ -63,8 +69,9 @@ interface UseAudioContext { export function useAudioContext( props: Props, ): UseAudioContext | null { - const [effectSoundVolume] = useSetting(soundEffectVolumeSetting); - const devices = useMediaDevices(); + const [soundEffectVolume] = useSetting(soundEffectVolumeSetting); + const { audioOutput } = useMediaDevices(); + const [audioContext, setAudioContext] = useState(); const [audioBuffers, setAudioBuffers] = useState>(); @@ -106,23 +113,30 @@ export function useAudioContext( if (audioContext && "setSinkId" in audioContext) { // https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/setSinkId // @ts-expect-error - setSinkId doesn't exist yet in types, maybe because it's not supported everywhere. - audioContext.setSinkId(devices.audioOutput.selectedId).catch((ex) => { + audioContext.setSinkId(audioOutput.selectedId).catch((ex) => { logger.warn("Unable to change sink for audio context", ex); }); } - }, [audioContext, devices]); + }, [audioContext, audioOutput.selectedId]); + const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig(); // Don't return a function until we're ready. if (!audioContext || !audioBuffers || props.muted) { return null; } + return { playSound: async (name): Promise => { if (!audioBuffers[name]) { logger.debug(`Tried to play a sound that wasn't buffered (${name})`); return; } - return playSound(audioContext, audioBuffers[name], effectSoundVolume); + return playSound( + audioContext, + audioBuffers[name], + soundEffectVolume * earpieceVolume, + earpiecePan, + ); }, }; } diff --git a/src/utils/test.ts b/src/utils/test.ts index 6e1b5457..51ed1ed2 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -27,12 +27,14 @@ import { type RemoteParticipant, type RemoteTrackPublication, type Room as LivekitRoom, + Track, } from "livekit-client"; import { randomUUID } from "crypto"; import { type RoomAndToDeviceEvents, type RoomAndToDeviceEventsHandlerMap, } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; +import { type TrackReference } from "@livekit/components-core"; import { LocalUserMediaViewModel, @@ -309,3 +311,24 @@ export class MockRTCSession extends TypedEventEmitter< return this; } } + +export const mockTrack = (identity: string): TrackReference => + ({ + participant: { + identity, + }, + publication: { + kind: Track.Kind.Audio, + source: "mic", + trackSid: "123", + track: { + attach: vi.fn(), + detach: vi.fn(), + setAudioContext: vi.fn(), + setWebAudioPlugins: vi.fn(), + setVolume: vi.fn(), + }, + }, + track: {}, + source: {}, + }) as unknown as TrackReference; From f6870ae5b4fdc3268ce6f7c88b34db21431d3183 Mon Sep 17 00:00:00 2001 From: fkwp <5071496+fkwp@users.noreply.github.com> Date: Thu, 15 May 2025 20:10:41 +0000 Subject: [PATCH 4/5] Translations updates --- locales/cs/app.json | 7 +- locales/da/app.json | 221 ++++++++++++++++++++++++++++++++++++++ locales/de/app.json | 9 +- locales/el/app.json | 45 +++++++- locales/et/app.json | 7 +- locales/fi/app.json | 224 +++++++++++++++++++++++++++++++++++++++ locales/id/app.json | 122 ++++++++++++++++++++- locales/lv/app.json | 148 ++++++++++++++++++++++++-- locales/ro/app.json | 66 +++++++++--- locales/ru/app.json | 22 ++-- locales/sk/app.json | 17 ++- locales/sv/app.json | 3 +- locales/uk/app.json | 9 +- locales/zh-Hans/app.json | 28 ++++- 14 files changed, 880 insertions(+), 48 deletions(-) create mode 100644 locales/da/app.json create mode 100644 locales/fi/app.json diff --git a/locales/cs/app.json b/locales/cs/app.json index a63a6de8..81f1ef9a 100644 --- a/locales/cs/app.json +++ b/locales/cs/app.json @@ -70,10 +70,12 @@ "livekit_server_info": "Informace o serveru LiveKit", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrix ID: {{id}}", + "mute_all_audio": "Ztlumit všechny zvuky (účastníci, reakce, zvuky připojení)", "show_connection_stats": "Zobrazit statistiky připojení", "show_non_member_tiles": "Zobrazit dlaždice pro nečlenská média", "url_params": "Parametry URL", - "use_new_membership_manager": "Použijte novou implementaci volání MembershipManager" + "use_new_membership_manager": "Použijte novou implementaci volání MembershipManager", + "use_to_device_key_transport": "Použít přenos klíčů do zařízení. Tím se vrátíte k přenosu klíčů do místnosti, když jiný účastník hovoru pošle klíč místnosti" }, "disconnected_banner": "Připojení k serveru bylo ztraceno.", "error": { @@ -164,6 +166,9 @@ "effect_volume_description": "Upravit hlasitost přehrávání reakcí a efektů zvednutých rukou.", "effect_volume_label": "Hlasitost zvukového efektu" }, + "background_blur_header": "Pozadí", + "background_blur_label": "Rozostřit pozadí videa", + "blur_not_supported_by_browser": "(Toto zařízení nepodporuje rozostření pozadí.)", "developer_tab_title": "Vývojář", "devices": { "camera": "Fotoaparát", diff --git a/locales/da/app.json b/locales/da/app.json new file mode 100644 index 00000000..4461e467 --- /dev/null +++ b/locales/da/app.json @@ -0,0 +1,221 @@ +{ + "a11y": { + "user_menu": "Brugermenu" + }, + "action": { + "close": "Luk", + "copy_link": "Kopiér link", + "edit": "Rediger", + "go": "Gå", + "invite": "Invitér", + "lower_hand": "Sænk hånd", + "no": "Nej", + "pick_reaction": "Vælg reaktion", + "raise_hand": "Ræk hånden op", + "register": "Registrér", + "remove": "Fjern", + "show_less": "Vis mindre", + "show_more": "Vis mere", + "sign_in": "Log ind", + "sign_out": "Log ud", + "submit": "Indsend", + "upload_file": "Upload fil" + }, + "analytics_notice": "Ved at deltage i denne beta giver du samtykke til indsamling af anonyme data, som vi bruger til at forbedre produktet. Du kan finde flere oplysninger om, hvilke data vi sporer, i vores <2>fortrolighedspolitik og vores <6>cookiepolitik.", + "app_selection_modal": { + "continue_in_browser": "Fortsæt i browseren", + "open_in_app": "Åbn i appen", + "text": "Klar til at deltage?", + "title": "Vælg app" + }, + "call_ended_view": { + "create_account_button": "Opret konto", + "create_account_prompt": "<0>Hvorfor ikke afslutte med at oprette en adgangskode for at beholde din konto? <1>Du kan beholde dit navn og indstille en avatar til brug ved fremtidige opkald ", + "feedback_done": "<0>Tak for din feedback! ", + "feedback_prompt": "<0>Vi vil meget gerne høre din feedback, så vi kan forbedre din oplevelse.", + "headline": "{{displayName}}, dit opkald er afsluttet.", + "not_now_button": "Ikke nu, vend tilbage til startskærmen", + "reconnect_button": "Tilslut igen", + "survey_prompt": "Hvordan gik det?" + }, + "call_name": "Navn på opkald", + "common": { + "analytics": "Analyse-værktøj", + "audio": "Lyd", + "avatar": "Avatar", + "back": "Tilbage", + "display_name": "Vist navn", + "encrypted": "Krypteret", + "home": "Hjem", + "loading": "Indlæser...", + "next": "Næste", + "options": "Valgmuligheder", + "password": "Adgangskode", + "preferences": "Foretrukne", + "profile": "Profil", + "reaction": "Reaktion", + "reactions": "Reaktioner", + "settings": "Indstillinger", + "unencrypted": "Ikke krypteret", + "username": "Brugernavn", + "video": "Video" + }, + "developer_mode": { + "crypto_version": "Krypto-version: {{version}}", + "debug_tile_layout_label": "Fejlfinding af fliselayout", + "device_id": "Enheds-id: {{id}}", + "duplicate_tiles_label": "Antal ekstra flisekopier pr. deltager", + "environment_variables": "Miljøvariabler", + "hostname": "Værtsnavn: {{hostname}}", + "livekit_server_info": "LiveKit Serverinfo", + "livekit_sfu": "LiveKit SFU: {{url}}", + "matrix_id": "Matrix ID: {{id}}", + "show_connection_stats": "Vis forbindelsesstatistik", + "show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer", + "url_params": "URL-parametre", + "use_new_membership_manager": "Brug den nye implementering af opkaldet MembershipManager", + "use_to_device_key_transport": "Bruges til at transportere enhedsnøgler. Dette vil falde tilbage til transport af værelsesnøgler, når et andet opkaldsmedlem sender en rumnøgle" + }, + "disconnected_banner": "Forbindelsen til serveren er gået tabt.", + "error": { + "call_is_not_supported": "Opkald er ikke understøttet", + "call_not_found": "Opkald ikke fundet", + "call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt.", + "connection_lost": "Forbindelsen gik tabt", + "connection_lost_description": "Du blev afbrudt fra opkaldet.", + "e2ee_unsupported": "Inkompatibel browser", + "e2ee_unsupported_description": "Din webbrowser understøtter ikke krypterede opkald. Understøttede browsere inkluderer Chrome, Safari og Firefox 117+.", + "generic": "Noget gik galt", + "generic_description": "Indsendelse af fejlfindingslogfiler hjælper os med at spore problemet.", + "insufficient_capacity": "Utilstrækkelig kapacitet", + "insufficient_capacity_description": "Serveren har nået sin maksimale kapacitet, og du kan ikke deltage i opkaldet på dette tidspunkt. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.", + "matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).", + "open_elsewhere": "Åbnet i en anden fane", + "open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.", + "unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode: <1> {{ errorCode }}). Kontakt venligst din serveradministrator." + }, + "group_call_loader": { + "banned_body": "Du er blevet spærret fra rummet.", + "banned_heading": "Spærret", + "call_ended_body": "Du er blevet fjernet fra opkaldet.", + "call_ended_heading": "Opkaldet afsluttet", + "knock_reject_body": "Din anmodning om at deltage blev afvist.", + "knock_reject_heading": "Adgang nægtet", + "reason": "Årsag: {{reason}}" + }, + "hangup_button_label": "Afslut opkald", + "header_label": "Element Ring hjem", + "header_participants_label": "Deltagere", + "invite_modal": { + "link_copied_toast": "Link kopieret til udklipsholder", + "title": "Inviter til dette opkald" + }, + "join_existing_call_modal": { + "join_button": "Ja, deltag i opkald", + "text": "Dette opkald findes allerede, vil du være med?", + "title": "Deltag i eksisterende opkald?" + }, + "layout_grid_label": "Gitter", + "layout_spotlight_label": "Spotlys", + "lobby": { + "ask_to_join": "Anmod om at deltage i opkaldet", + "join_as_guest": "Deltag som gæst", + "join_button": "Deltag i opkald", + "leave_button": "Tilbage til seneste", + "waiting_for_invite": "Anmodning sendt! Venter på tilladelse til at deltage..." + }, + "log_in": "Log ind", + "logging_in": "Logger ind...", + "login_auth_links": "<0>Opret en konto eller <2> få adgang som gæst ", + "login_auth_links_prompt": "Ikke registreret endnu?", + "login_subheading": "For at fortsætte til Element", + "login_title": "Login", + "microphone_off": "Mikrofon slukket", + "microphone_on": "Mikrofon tændt", + "mute_microphone_button_label": "Slå mikrofonen fra", + "participant_count_one": "{{count, number}}", + "participant_count_other": "{{count, number}}", + "qr_code": "QR-kode", + "rageshake_button_error_caption": "Prøv at sende logfiler igen", + "rageshake_request_modal": { + "body": "En anden bruger på dette opkald har et problem. For bedre at kunne diagnosticere sådanne problemer, vil vi gerne indsamle en fejlfindingslog.", + "title": "Anmodning om fejlfindingslogfil" + }, + "rageshake_send_logs": "Send fejlfindingslogfiler", + "rageshake_sending": "Sender...", + "rageshake_sending_logs": "Afsendelse af fejlfindingslogfiler...", + "rageshake_sent": "Tak!", + "recaptcha_dismissed": "Recaptcha afvist", + "recaptcha_not_loaded": "Recaptcha ikke indlæst", + "recaptcha_ssla_caption": "Dette websted er beskyttet af ReCAPTCHA og Googles <2>Privatlivspolitik og <6>Servicevilkår gælder.<9>Ved at klikke på \"Registrer\" accepterer du vores <12>Software og Services Licensaftale (SSLA)", + "register": { + "passwords_must_match": "Adgangskoderne skal være identiske", + "registering": "Registrering..." + }, + "register_auth_links": "<0>Har du allerede en konto? <1><0>Log ind eller <2> få adgang som gæst ", + "register_confirm_password_label": "Bekræft adgangskode", + "register_heading": "Opret din konto", + "return_home_button": "Vend tilbage til startskærmen", + "room_auth_view_continue_button": "Fortsæt", + "room_auth_view_ssla_caption": "Ved at klikke på „Deltag i opkald nu“ accepterer du vores <2> Software og Services Licensaftale (SSLA) ", + "screenshare_button_label": "Del din skærm", + "settings": { + "audio_tab": { + "effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.", + "effect_volume_label": "Lydstyrke for lydeffekter" + }, + "developer_tab_title": "Udvikler", + "devices": { + "camera": "Kamera", + "camera_numbered": "Kamera {{n}}", + "default": "Standard", + "default_named": "Standard <2>({{name}})", + "microphone": "Mikrofon", + "microphone_numbered": "Mikrofon {{n}}", + "speaker": "Højttaler", + "speaker_numbered": "Højttaler {{n}}" + }, + "feedback_tab_body": "Hvis du oplever problemer eller bare gerne vil give feedback, kan du sende os en kort beskrivelse herunder.", + "feedback_tab_description_label": "Din tilbagemelding", + "feedback_tab_h4": "Indsend feedback", + "feedback_tab_send_logs_label": "Medtag fejlfindingslogfiler", + "feedback_tab_thank_you": "Tak, vi har modtaget din feedback!", + "feedback_tab_title": "Feedback", + "opt_in_description": "<0><1>Du kan trække dit samtykke tilbage ved at fjerne markeringen i dette felt. Hvis du i øjeblikket er i gang med et opkald, træder denne indstilling i kraft ved afslutningen af opkaldet.", + "preferences_tab": { + "developer_mode_label": "Udviklertilstand", + "developer_mode_label_description": "Aktivér udviklertilstand og vis fanen udviklerindstillinger.", + "introduction": "Her kan du konfigurere ekstra muligheder for en forbedret oplevelse.", + "reactions_play_sound_description": "Afspil en lydeffekt, når nogen sender en reaktion i et opkald.", + "reactions_play_sound_label": "Afspil reaktionslyde", + "reactions_show_description": "Vis en animation, når nogen sender en reaktion.", + "reactions_show_label": "Vis reaktioner", + "show_hand_raised_timer_description": "Vis en timer, når en deltager rækker hånden", + "show_hand_raised_timer_label": "Vis varighed af håndsoprækning" + } + }, + "star_rating_input_label_one": "{{count}} stjerne", + "star_rating_input_label_other": "{{count}} stjerner", + "start_new_call": "Start nyt opkald", + "start_video_button_label": "Start video", + "stop_screenshare_button_label": "Skærmen bliver delt", + "stop_video_button_label": "Stop video", + "submitting": "Indsender...", + "switch_camera": "Skift kamera", + "unauthenticated_view_body": "Ikke registreret endnu? <2>Opret en konto ", + "unauthenticated_view_login_button": "Log ind på din konto", + "unauthenticated_view_ssla_caption": "Ved at klikke på \"Start\" accepterer du vores <2>Software og Services Licensaftale (SSLA)", + "unmute_microphone_button_label": "Slå mikrofonen til", + "version": "{{productName}} version: {{version}}", + "video_tile": { + "always_show": "Vis altid", + "camera_starting": "Indlæser video", + "change_fit_contain": "Tilpas til rammen", + "collapse": "Fold sammen", + "expand": "Udvid", + "mute_for_me": "Slå lyden fra for mig", + "muted_for_me": "Dæmpet for mig", + "volume": "Lydstyrke", + "waiting_for_media": "Venter på medier..." + } +} diff --git a/locales/de/app.json b/locales/de/app.json index 6dccde05..67aab44c 100644 --- a/locales/de/app.json +++ b/locales/de/app.json @@ -61,6 +61,7 @@ "video": "Video" }, "developer_mode": { + "always_show_iphone_earpiece": "iPhone-Ohrhörer-Option auf allen Plattformen anzeigen", "crypto_version": "Krypto-Version: {{version}}", "debug_tile_layout_label": "Kachel-Layout debuggen", "device_id": "Geräte-ID: {{id}}", @@ -70,10 +71,12 @@ "livekit_server_info": "LiveKit-Server Informationen", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrix-ID: {{id}}", + "mute_all_audio": "Stummschalten aller Audiosignale (Teilnehmer, Reaktionen, Beitrittsgeräusche)", "show_connection_stats": "Verbindungsstatistiken anzeigen", "show_non_member_tiles": "Kacheln für Nicht-Mitgliedermedien anzeigen", "url_params": "URL-Parameter", - "use_new_membership_manager": "Neuen MembershipManager verwenden" + "use_new_membership_manager": "Neuen MembershipManager verwenden", + "use_to_device_key_transport": "To-Device media E2EE Schlüssel-Transport verwenden. Falls ein anderer Teilnehmer bereits den Raumschlüssel-Transport verwendet, wird automatisch auf Raumschlüssel-Transport zurückgegriffen." }, "disconnected_banner": "Die Verbindung zum Server wurde getrennt.", "error": { @@ -163,12 +166,16 @@ "effect_volume_description": "Lautstärke anpassen, mit der Reaktionen und Handmeldungen abgespielt werden.", "effect_volume_label": "Lautstärke der Soundeffekte" }, + "background_blur_header": "Hintergrund", + "background_blur_label": "Unschärfeeffekt für den Hintergrund aktivieren", + "blur_not_supported_by_browser": "(Hintergrundunschärfe wird von diesem Gerät nicht unterstützt.)", "developer_tab_title": "Entwickler", "devices": { "camera": "Kamera", "camera_numbered": "Kamera {{n}}", "default": "Standard", "default_named": "Standard<2> ({{name}} )", + "earpiece": "Ohrhörer", "microphone": "Mikrofon", "microphone_numbered": "Mikrofon{{n}}", "speaker": "Lautsprecher", diff --git a/locales/el/app.json b/locales/el/app.json index 6068a1ff..6eec5278 100644 --- a/locales/el/app.json +++ b/locales/el/app.json @@ -4,15 +4,30 @@ }, "action": { "close": "Κλείσιμο", + "copy_link": "Αντιγραφή συνδέσμου", + "edit": "Επεξεργασία", "go": "Μετάβαση", + "invite": "Πρόσκληση", + "lower_hand": "Κατεβάστε το χέρι", "no": "Όχι", + "pick_reaction": "Επιλέξτε αντίδραση", + "raise_hand": "Σηκώστε το χέρι", "register": "Εγγραφή", "remove": "Αφαίρεση", + "show_less": "Εμφάνιση λιγότερων", + "show_more": "Εμφάνιση περισσότερων", "sign_in": "Σύνδεση", "sign_out": "Αποσύνδεση", - "submit": "Υποβολή" + "submit": "Υποβολή", + "upload_file": "Μεταφόρτωση αρχείου" + }, + "analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου και στην <6>Πολιτική cookies.", + "app_selection_modal": { + "continue_in_browser": "Συνέχεια στο πρόγραμμα περιήγησης", + "open_in_app": "Ανοίξτε στην εφαρμογή", + "text": "Έτοιμοι να συμμετάσχετε?", + "title": "Επιλέξτε εφαρμογή" }, - "analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου και στην <5>Πολιτική cookies.", "call_ended_view": { "create_account_button": "Δημιουργία λογαριασμού", "create_account_prompt": "<0>Γιατί να μην ολοκληρώσετε με τη δημιουργία ενός κωδικού πρόσβασης για τη διατήρηση του λογαριασμού σας;<1>Θα μπορείτε να διατηρήσετε το όνομά σας και να ορίσετε ένα avatar για χρήση σε μελλοντικές κλήσεις.", @@ -20,19 +35,45 @@ "feedback_prompt": "<0>Θα θέλαμε να ακούσουμε τα σχόλιά σας ώστε να βελτιώσουμε την εμπειρία σας.", "headline": "{{displayName}}, η κλήση σας τερματίστηκε.", "not_now_button": "Όχι τώρα, επιστροφή στην αρχική οθόνη", + "reconnect_button": "Επανασύνδεση", "survey_prompt": "Πώς σας φάνηκε;" }, + "call_name": "Όνομα κλήσης", "common": { + "analytics": "Δεδομένα ανάλυσης", "audio": "Ήχος", + "avatar": "Εικόνα Προφίλ", + "back": "Πίσω", "display_name": "Εμφανιζόμενο όνομα", + "encrypted": "Κρυπτογραφημένο", "home": "Αρχική", "loading": "Φόρτωση…", + "next": "Επόμενο", + "options": "Επιλογές", "password": "Κωδικός", + "preferences": "Προτιμήσεις", "profile": "Προφίλ", + "reaction": "Αντίδραση", + "reactions": "Αντιδράσεις", "settings": "Ρυθμίσεις", + "unencrypted": "Μη κρυπτογραφημένο", "username": "Όνομα χρήστη", "video": "Βίντεο" }, + "developer_mode": { + "crypto_version": "Έκδοση κρυπτογράφησης: {{version}}", + "debug_tile_layout_label": "Διάταξη πλακιδίων εντοπισμού σφαλμάτων", + "device_id": "Αναγνωριστικό συσκευής: {{id}}", + "duplicate_tiles_label": "Αριθμός επιπλέον αντιγράφων πλακιδίων ανά συμμετέχοντα", + "environment_variables": "Μεταβλητές περιβάλλοντος", + "hostname": "Όνομα κεντρικού υπολογιστή: {{hostname}}", + "livekit_server_info": "Πληροφορίες διακομιστή LiveKit", + "livekit_sfu": "LiveKit SFU: {{url}}", + "matrix_id": "Αναγνωριστικό Matrix: {{id}}", + "show_connection_stats": "Εμφάνιση στατιστικών σύνδεσης", + "show_non_member_tiles": "Εμφάνιση πλακιδίων για μέσα μη-μελών", + "url_params": "Παράμετροι URL" + }, "header_label": "Element Κεντρική Οθόνη Κλήσεων", "join_existing_call_modal": { "join_button": "Ναι, συμμετοχή στην κλήση", diff --git a/locales/et/app.json b/locales/et/app.json index d3db8c74..ed32a6fc 100644 --- a/locales/et/app.json +++ b/locales/et/app.json @@ -70,10 +70,12 @@ "livekit_server_info": "LiveKiti serveri teave", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrixi kasutajatunnus: {{id}}", + "mute_all_audio": "Summuta kõik helid (osalejad, regeerimised, liitumise helid)", "show_connection_stats": "Näita ühenduse statistikat", "show_non_member_tiles": "Näita ka mitteseotud meedia paane", "url_params": "Võrguaadressi parameetrid", - "use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni" + "use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni", + "use_to_device_key_transport": "Kasuta seadmepõhist krüptovõtmete vahetust. Kui jututoa liige peaks saatma jututoakohase krüptovõtme, siis kasuta jututoakohast võtmevahetust" }, "disconnected_banner": "Võrguühendus serveriga on katkenud.", "error": { @@ -163,6 +165,9 @@ "effect_volume_description": "Häälesta helivaljust, mida kasutatakse käe tõstmisel ja regeerimisel", "effect_volume_label": "Efektide helivajlus" }, + "background_blur_header": "Taust", + "background_blur_label": "Hägusta video taust", + "blur_not_supported_by_browser": "(Tausta hägustamine pole selles seadmes toetatud.)", "developer_tab_title": "Arendaja", "devices": { "camera": "Kaamera", diff --git a/locales/fi/app.json b/locales/fi/app.json new file mode 100644 index 00000000..9e8a463c --- /dev/null +++ b/locales/fi/app.json @@ -0,0 +1,224 @@ +{ + "a11y": { + "user_menu": "Käyttäjävalikko" + }, + "action": { + "close": "Sulje", + "copy_link": "Kopioi linkki", + "edit": "Muokkaa", + "go": "Siirry", + "invite": "Kutsu", + "lower_hand": "Laske käsi", + "no": "Ei", + "pick_reaction": "Valitse reaktio", + "raise_hand": "Nosta käsi", + "register": "Rekisteröidy", + "remove": "Poista", + "show_less": "Näytä vähemmän", + "show_more": "Näytä lisää", + "sign_in": "Kirjaudu sisään", + "sign_out": "Kirjaudu ulos", + "submit": "Lähetä", + "upload_file": "Lähetä tiedosto" + }, + "analytics_notice": "Osallistumalla tähän betaan hyväksyt nimettömien tietojen keräämisen, joita käytämme tuotteen parantamiseen. Löydät lisätietoa siitä, mitä tietoja seuraamme meidän <2> Tietosuojakäytännöstä ja <6>Evästekäytännöstä .", + "app_selection_modal": { + "continue_in_browser": "Jatka selaimessa", + "open_in_app": "Avaa sovelluksessa", + "text": "Oletko valmis liittymään?", + "title": "Valitse sovellus" + }, + "call_ended_view": { + "create_account_button": "Luo tili", + "create_account_prompt": "<0>Miksi et viimeistelisi määrittämällä salasanaa tilisi säilyttämiseksi?<1>Voit säilyttää nimesi ja asettaa avatarin käytettäväksi tulevissa puheluissa", + "feedback_done": "<0>Kiitos palautteestasi!", + "feedback_prompt": "<0>Haluaisimme kuulla palautteesi, jotta voimme parantaa käyttökokemustasi.", + "headline": "{{displayName}}, puhelusi on päättynyt.", + "not_now_button": "Ei nyt, palaa aloitusnäyttöön", + "reconnect_button": "Yhdistä uudelleen", + "survey_prompt": "Miten se meni?" + }, + "call_name": "Puhelun nimi", + "common": { + "analytics": "Analytiikka", + "audio": "Ääni", + "avatar": "Avatar", + "back": "Takaisin", + "display_name": "Näyttönimi", + "encrypted": "Salattu", + "home": "Etusivu", + "loading": "Ladataan...", + "next": "Seuraava", + "options": "Vaihtoehdot", + "password": "Salasana", + "preferences": "Asetukset", + "profile": "Profiili", + "reaction": "Reaktio", + "reactions": "Reaktiot", + "settings": "Asetukset", + "unencrypted": "Ei salattu", + "username": "Käyttäjänimi", + "video": "Video" + }, + "developer_mode": { + "crypto_version": "Kryptoversio: {{version}}", + "debug_tile_layout_label": "Laattojen asettelun vianmääritys", + "device_id": "Laitteen tunnus: {{id}}", + "duplicate_tiles_label": "Lisälaattakopioiden määrä osallistujaa kohti", + "environment_variables": "Ympäristömuuttujat", + "hostname": "Isäntänimi: {{hostname}}", + "livekit_server_info": "LiveKit-palvelimen tiedot", + "livekit_sfu": "LiveKit SFU: {{url}}", + "matrix_id": "Matrix tunnus: {{id}}", + "show_connection_stats": "Näytä yhteystilastot", + "show_non_member_tiles": "Näytä laatat ei-jäsenien medialle", + "url_params": "URL-parametrit", + "use_new_membership_manager": "Käytä uutta puhelun MembershipManagerin toteutusta", + "use_to_device_key_transport": "Käytä laitteen avainten kuljetusta. Tämä palaa huoneen avainten siirtoon, kun toinen puhelun jäsen lähettää huoneavaimen" + }, + "disconnected_banner": "Yhteys palvelimeen on katkennut.", + "error": { + "call_is_not_supported": "Puhelua ei tueta", + "call_not_found": "Puhelua ei löydy", + "call_not_found_description": "<0>Kyseinen linkki ei näytä kuuluvan mihinkään olemassa olevaan puheluun. Tarkista, että sinulla on oikea linkki, tai <1>luo uusi linkki.", + "connection_lost": "Yhteys katkesi", + "connection_lost_description": "Sinut katkaistiin puhelusta.", + "e2ee_unsupported": "Yhteensopimaton selain", + "e2ee_unsupported_description": "Verkkoselaimesi ei tue salattuja puheluita. Tuettuja selaimia ovat Chrome, Safari ja Firefox 117+.", + "generic": "Jokin meni pieleen", + "generic_description": "Vianmäärityslokien lähettäminen auttaa meitä jäljittämään ongelman.", + "insufficient_capacity": "Riittämätön kapasiteetti", + "insufficient_capacity_description": "Palvelin on saavuttanut maksimikapasiteettinsa, etkä voi liittyä puheluun tällä hetkellä. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.", + "matrix_rtc_focus_missing": "Palvelinta ei ole määritetty toimimaan {{brand}} -sovelluksen kanssa. Ota yhteyttä palvelimen ylläpitäjään (Verkkotunnus: {{domain}}, Virhekoodi: {{ errorCode }}).", + "open_elsewhere": "Avattu toisessa välilehdessä", + "open_elsewhere_description": "{{brand}} on avattu toisessa välilehdessä. Jos tämä ei kuulosta oikealta, yritä ladata sivu uudelleen.", + "unexpected_ec_error": "Tapahtui odottamaton virhe (<0>Virhekoodi: <1>{{ errorCode }}). Ota yhteyttä palvelimen ylläpitäjään." + }, + "group_call_loader": { + "banned_body": "Sinulle on annettu porttikielto huoneesta.", + "banned_heading": "Kielletty", + "call_ended_body": "Sinut on poistettu puhelusta.", + "call_ended_heading": "Puhelu päättyi", + "knock_reject_body": "Liittymispyyntösi hylättiin.", + "knock_reject_heading": "Pääsy kielletty", + "reason": "Syy: {{reason}}" + }, + "hangup_button_label": "Lopeta puhelu", + "header_label": "Element Call Etusivu", + "header_participants_label": "Osallistujat", + "invite_modal": { + "link_copied_toast": "Linkki kopioitu leikepöydälle", + "title": "Kutsu tähän puheluun" + }, + "join_existing_call_modal": { + "join_button": "Kyllä, liity puheluun", + "text": "Tämä puhelu on jo olemassa, haluatko liittyä siihen?", + "title": "Liity olemassa olevaan puheluun?" + }, + "layout_grid_label": "Ruudukko", + "layout_spotlight_label": "Valokeila", + "lobby": { + "ask_to_join": "Pyydä liittymistä puheluun", + "join_as_guest": "Liity vieraana", + "join_button": "Liity puheluun", + "leave_button": "Takaisin viimeisimpiin puheluihin", + "waiting_for_invite": "Pyyntö lähetetty! Odotetaan lupaa liittyä…" + }, + "log_in": "Kirjaudu sisään", + "logging_in": "Kirjaudutaan sisään…", + "login_auth_links": "<0>Luo tili tai <2>Käytä vieraana", + "login_auth_links_prompt": "Etkö ole vielä rekisteröitynyt?", + "login_subheading": "Jatkaaksesi Elementiin", + "login_title": "Kirjaudu sisään", + "microphone_off": "Mikrofoni pois päältä", + "microphone_on": "Mikrofoni päällä", + "mute_microphone_button_label": "Mykistä mikrofoni", + "participant_count_one": "{{count, number}}", + "participant_count_other": "{{count, number}}", + "qr_code": "QR-koodi", + "rageshake_button_error_caption": "Yritä uudelleen lokien lähettämistä", + "rageshake_request_modal": { + "body": "Toisella käyttäjällä tässä puhelussa on ongelma. Jotta voimme diagnosoida nämä ongelmat paremmin, haluaisimme kerätä virheenkorjauslokin.", + "title": "Virheenkorjauslokipyyntö" + }, + "rageshake_send_logs": "Lähetä virheenkorjauslokit", + "rageshake_sending": "Lähetetään…", + "rageshake_sending_logs": "Lähetetään virheenkorjauslokeja…", + "rageshake_sent": "Kiitos!", + "recaptcha_dismissed": "Recaptcha hylätty", + "recaptcha_not_loaded": "Recaptcha ei ole ladattu", + "recaptcha_ssla_caption": "Tämä sivusto on suojattu ReCAPTCHA:lla, ja siihen sovelletaan Googlen <2>Tietosuojakäytäntöä ja <6>Palveluehtoja.<9>Klikkaamalla \"Rekisteröidy\" hyväksyt <12>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)", + "register": { + "passwords_must_match": "Salasanojen on täsmättävä", + "registering": "Rekisteröidään…" + }, + "register_auth_links": "<0>Onko sinulla jo tili?<1><0>Kirjaudu sisään tai <2>Käytä vieraana", + "register_confirm_password_label": "Vahvista salasana", + "register_heading": "Luo tilisi", + "return_home_button": "Palaa aloitusnäyttöön", + "room_auth_view_continue_button": "Jatka", + "room_auth_view_ssla_caption": "Klikkaamalla \"Liity puheluun nyt\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)", + "screenshare_button_label": "Jaa näyttö", + "settings": { + "audio_tab": { + "effect_volume_description": "Säädä äänenvoimakkuutta, jolla reaktioiden ja käden nostojen ääniefektit toistetaan.", + "effect_volume_label": "Ääniefektien äänenvoimakkuus" + }, + "background_blur_header": "Tausta", + "background_blur_label": "Sumenna videon tausta", + "blur_not_supported_by_browser": "(Tämä laite ei tue taustan sumennusta.)", + "developer_tab_title": "Kehittäjä", + "devices": { + "camera": "Kamera", + "camera_numbered": "Kamera {{n}}", + "default": "Oletus", + "default_named": "Oletus <2>({{name}})", + "microphone": "Mikrofoni", + "microphone_numbered": "Mikrofoni {{n}}", + "speaker": "Kaiutin", + "speaker_numbered": "Kaiutin {{n}}" + }, + "feedback_tab_body": "Jos sinulla on ongelmia tai haluat vain antaa palautetta, lähetä meille lyhyt kuvaus alla.", + "feedback_tab_description_label": "Palautteesi", + "feedback_tab_h4": "Lähetä palautetta", + "feedback_tab_send_logs_label": "Sisällytä virheenkorjauslokit", + "feedback_tab_thank_you": "Kiitos, saimme palautteesi!", + "feedback_tab_title": "Palaute", + "opt_in_description": "<0><1>Voit peruuttaa suostumuksesi poistamalla tämän ruudun valinnan. Jos puhelu on käynnissä, tämä asetus tulee voimaan puhelun lopussa.", + "preferences_tab": { + "developer_mode_label": "Kehittäjätila", + "developer_mode_label_description": "Ota kehittäjätila käyttöön ja näytä kehittäjäasetukset-välilehti.", + "introduction": "Täällä voit määrittää lisävaihtoehtoja parempaa käyttökokemusta varten.", + "reactions_play_sound_description": "Toista äänitefekti, kun joku lähettää reaktion puheluun.", + "reactions_play_sound_label": "Toista reaktioäänet", + "reactions_show_description": "Näytä animaatio, kun joku lähettää reaktion.", + "reactions_show_label": "Näytä reaktiot", + "show_hand_raised_timer_description": "Näytä ajastin, kun osallistuja nostaa kätensä", + "show_hand_raised_timer_label": "Näytä kädennoston kesto" + } + }, + "star_rating_input_label_one": "{{count}} tähti", + "star_rating_input_label_other": "{{count}} tähteä", + "start_new_call": "Aloita uusi puhelu", + "start_video_button_label": "Aloita video", + "stop_screenshare_button_label": "Jaetaan näyttöä", + "stop_video_button_label": "Lopeta video", + "submitting": "Lähetetään…", + "switch_camera": "Vaihda kameraa", + "unauthenticated_view_body": "Etkö ole vielä rekisteröitynyt? <2>Luo tili", + "unauthenticated_view_login_button": "Kirjaudu tilillesi", + "unauthenticated_view_ssla_caption": "Klikkaamalla \"Siirry\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)", + "unmute_microphone_button_label": "Poista mikrofonin mykistys", + "version": "{{productName}} versio: {{version}}", + "video_tile": { + "always_show": "Näytä aina", + "camera_starting": "Videota ladataan...", + "change_fit_contain": "Sovita kehykseen", + "collapse": "Supista", + "expand": "Laajenna", + "mute_for_me": "Mykistä minulle", + "muted_for_me": "Mykistetty minulle", + "volume": "Äänenvoimakkuus", + "waiting_for_media": "Odotetaan mediaa..." + } +} diff --git a/locales/id/app.json b/locales/id/app.json index 377d23d5..ac1c6221 100644 --- a/locales/id/app.json +++ b/locales/id/app.json @@ -5,14 +5,21 @@ "action": { "close": "Tutup", "copy_link": "Salin tautan", + "edit": "Sunting", "go": "Bergabung", "invite": "Undang", + "lower_hand": "Turunkan tangan", "no": "Tidak", + "pick_reaction": "Pilih reaksi", + "raise_hand": "Angkat tangan", "register": "Daftar", "remove": "Hapus", + "show_less": "Tampilkan lebih sedikit", + "show_more": "Tampilkan lebih banyak", "sign_in": "Masuk", "sign_out": "Keluar", - "submit": "Kirim" + "submit": "Kirim", + "upload_file": "Unggah berkas" }, "analytics_notice": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi dan <5>Kebijakan Kuki kami.", "app_selection_modal": { @@ -33,17 +40,69 @@ }, "call_name": "Nama panggilan", "common": { + "analytics": "Analitik", + "audio": "Audio", + "avatar": "Avatar", + "back": "Kembali", "display_name": "Nama tampilan", "encrypted": "Terenkripsi", "home": "Beranda", "loading": "Memuat…", + "next": "Berikutnya", + "options": "Opsi", "password": "Kata sandi", + "preferences": "Preferensi", "profile": "Profil", + "reaction": "Reaksi", + "reactions": "Reaksi", "settings": "Pengaturan", "unencrypted": "Tidak terenkripsi", - "username": "Nama pengguna" + "username": "Nama pengguna", + "video": "Video" + }, + "developer_mode": { + "crypto_version": "Versi kripto: {{version}}", + "debug_tile_layout_label": "Awakutu tata letak ubin", + "device_id": "ID perangkat: {{id}}", + "duplicate_tiles_label": "Jumlah salinan ubin tambahan per peserta", + "environment_variables": "Variabel lingkungan", + "hostname": "Nama hos: {{hostname}}", + "livekit_server_info": "Info Server LiveKit", + "livekit_sfu": "SFU LiveKit: {{url}}", + "matrix_id": "ID Matrix: {{id}}", + "show_connection_stats": "Tampilkan statistik koneksi", + "show_non_member_tiles": "Tampilkan ubin untuk media non-anggota", + "url_params": "Parameter URL", + "use_new_membership_manager": "Gunakan implementasi baru dari panggilan MembershipManager", + "use_to_device_key_transport": "Gunakan untuk transportasi kunci perangkat. Ini akan kembali ke transportasi kunci ruangan ketika anggota panggilan lain mengirim kunci ruangan" }, "disconnected_banner": "Koneksi ke server telah hilang.", + "error": { + "call_is_not_supported": "Panggilan tidak didukung", + "call_not_found": "Panggilan tidak ditemukan", + "call_not_found_description": "<0>Tautan itu tampaknya bukan milik panggilan yang ada. Periksa apakah Anda memiliki tautan yang tepat, atau <1> buat yang baru.", + "connection_lost": "Koneksi terputus", + "connection_lost_description": "Anda terputus dari panggilan.", + "e2ee_unsupported": "Peramban tidak kompatibel", + "e2ee_unsupported_description": "Peramban web Anda tidak mendukung panggilan terenkripsi. Peramban yang didukung meliputi Chrome, Safari, dan Firefox 117+.", + "generic": "Ada yang salah", + "generic_description": "Mengirimkan log awakutu akan membantu kami melacak masalah.", + "insufficient_capacity": "Kapasitas tidak mencukupi", + "insufficient_capacity_description": "Server telah mencapai kapasitas maksimum dan Anda tidak dapat bergabung dalam panggilan saat ini. Coba lagi nanti, atau hubungi admin server Anda jika masalah masih berlanjut.", + "matrix_rtc_focus_missing": "Server tidak dikonfigurasi untuk bekerja dengan {{brand}}. Silakan hubungi admin server Anda (Domain: {{domain}}, Kode Kesalahan: {{ errorCode }}).", + "open_elsewhere": "Dibuka di tab lain", + "open_elsewhere_description": "{{brand}} telah dibuka di tab lain. Jika sepertinya tidak benar, coba muat ulang halaman.", + "unexpected_ec_error": "Terjadi kesalahan tak terduga (<0> Kode Kesalahan:<1>{{ errorCode }}). Silakan hubungi admin server Anda." + }, + "group_call_loader": { + "banned_body": "Anda telah dilarang dari ruangan.", + "banned_heading": "Dilarang", + "call_ended_body": "Anda telah dikeluarkan dari panggilan.", + "call_ended_heading": "Panggilan berakhir", + "knock_reject_body": "Permintaan Anda untuk bergabung ditolak.", + "knock_reject_heading": "Akses ditolak", + "reason": "Alasan: {{reason}}" + }, "hangup_button_label": "Akhiri panggilan", "header_label": "Beranda Element Call", "header_participants_label": "Peserta", @@ -59,15 +118,23 @@ "layout_grid_label": "Kisi", "layout_spotlight_label": "Sorotan", "lobby": { + "ask_to_join": "Permintaan untuk bergabung dalam panggilan", + "join_as_guest": "Bergabung sebagai tamu", "join_button": "Bergabung ke panggilan", - "leave_button": "Kembali ke terkini" + "leave_button": "Kembali ke terkini", + "waiting_for_invite": "Permintaan terkirim! Menunggu izin untuk bergabung…" }, + "log_in": "Masuk", "logging_in": "Memasuki…", "login_auth_links": "<0>Buat akun Atau <2>Akses sebagai tamu", + "login_auth_links_prompt": "Belum terdaftar?", + "login_subheading": "Untuk melanjutkan ke Element", "login_title": "Masuk", "microphone_off": "Mikrofon dimatikan", "microphone_on": "Mikrofon dinyalakan", "mute_microphone_button_label": "Matikan mikrofon", + "participant_count_other": "{{count, number}}", + "qr_code": "Kode QR", "rageshake_button_error_caption": "Kirim ulang catatan", "rageshake_request_modal": { "body": "Pengguna yang lain di panggilan ini sedang mengalami masalah. Supaya dapat mendiagnosa masalah ini, kami ingin mengumpulkan sebuah catatan pengawakutuan.", @@ -79,23 +146,55 @@ "rageshake_sent": "Terima kasih!", "recaptcha_dismissed": "Recaptcha ditutup", "recaptcha_not_loaded": "Recaptcha tidak dimuat", + "recaptcha_ssla_caption": "Situs ini dilindungi oleh ReCAPTCHA dan <2>Kebijakan Privasi dan <6>Ketentuan Layanan Google berlaku.<9>Dengan mengeklik \"Daftar\", Anda menyetujui <12>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami", "register": { "passwords_must_match": "Kata sandi harus cocok", "registering": "Mendaftarkan…" }, "register_auth_links": "<0>Sudah punya akun?<1><0>Masuk Atau <2>Akses sebagai tamu", "register_confirm_password_label": "Konfirmasi kata sandi", + "register_heading": "Buat akun Anda", "return_home_button": "Kembali ke layar beranda", + "room_auth_view_continue_button": "Lanjutkan", + "room_auth_view_ssla_caption": "Dengan mengeklik “Gabung panggilan sekarang”, Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami", "screenshare_button_label": "Bagikan layar", "settings": { + "audio_tab": { + "effect_volume_description": "Sesuaikan volume saat reaksi dan efek tangan terangkat diputar.", + "effect_volume_label": "Volume efek suara" + }, + "background_blur_header": "Latar belakang", + "background_blur_label": "Buramkan latar belakang video", + "blur_not_supported_by_browser": "(Pemburaman latar belakang tidak didukung oleh perangkat ini.)", "developer_tab_title": "Pengembang", + "devices": { + "camera": "Kamera", + "camera_numbered": "Kamera {{n}}", + "default": "Bawaan", + "default_named": "Bawaan <2>({{name}})", + "microphone": "Mikrofon", + "microphone_numbered": "Mikrofon {{n}}", + "speaker": "Speaker", + "speaker_numbered": "Speaker {{n}}" + }, "feedback_tab_body": "Jika Anda mengalami masalah atau hanya ingin memberikan masukan, silakan kirimkan kami deskripsi pendek di bawah.", "feedback_tab_description_label": "Masukan Anda", "feedback_tab_h4": "Kirim masukan", "feedback_tab_send_logs_label": "Termasuk catatan pengawakutuan", "feedback_tab_thank_you": "Terima kasih, kami telah menerima masukan Anda!", "feedback_tab_title": "Masukan", - "opt_in_description": "<0><1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan." + "opt_in_description": "<0><1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan.", + "preferences_tab": { + "developer_mode_label": "Mode pengembang", + "developer_mode_label_description": "Aktifkan mode pengembang dan tampilkan tab pengaturan pengembang.", + "introduction": "Di sini Anda dapat mengonfigurasi opsi tambahan untuk pengalaman yang lebih baik.", + "reactions_play_sound_description": "Mainkan efek suara ketika seseorang mengirim reaksi ke panggilan.", + "reactions_play_sound_label": "Putar suara reaksi", + "reactions_show_description": "Tampilkan animasi ketika ada yang mengirimkan reaksi.", + "reactions_show_label": "Tampilkan reaksi", + "show_hand_raised_timer_description": "Tampilkan pengatur waktu saat peserta mengangkat tangannya", + "show_hand_raised_timer_label": "Tampilkan durasi angkat tangan" + } }, "star_rating_input_label_one": "{{count}} bintang", "star_rating_input_label_other": "{{count}} bintang", @@ -104,8 +203,21 @@ "stop_screenshare_button_label": "Berbagi layar", "stop_video_button_label": "Matikan video", "submitting": "Mengirim…", + "switch_camera": "Ganti kamera", "unauthenticated_view_body": "Belum terdaftar? <2>Buat sebuah akun", "unauthenticated_view_login_button": "Masuk ke akun Anda", + "unauthenticated_view_ssla_caption": "Dengan mengeklik \"Go\", Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami", "unmute_microphone_button_label": "Nyalakan mikrofon", - "version": "Versi: {{version}}" + "version": "Versi: {{version}}", + "video_tile": { + "always_show": "Selalu tampilkan", + "camera_starting": "Memuat video...", + "change_fit_contain": "Sesuai dengan bingkai", + "collapse": "Tutup", + "expand": "Buka", + "mute_for_me": "Bisukan untuk saya", + "muted_for_me": "Dibisukan untuk saya", + "volume": "Volume", + "waiting_for_media": "Menunggu media..." + } } diff --git a/locales/lv/app.json b/locales/lv/app.json index bc281463..4c351d97 100644 --- a/locales/lv/app.json +++ b/locales/lv/app.json @@ -4,15 +4,30 @@ }, "action": { "close": "Aizvērt", + "copy_link": "Kopēt saiti", + "edit": "Labot", "go": "Aiziet", + "invite": "Uzaicināt", + "lower_hand": "Nolaist roku", "no": "Nē", + "pick_reaction": "Reaģēt", + "raise_hand": "Pacelt roku", "register": "Reģistrēties", "remove": "Noņemt", + "show_less": "Rādīt mazāk", + "show_more": "Rādīt vairāk", "sign_in": "Pieteikties", "sign_out": "Atteikties", - "submit": "Iesniegt" + "submit": "Iesniegt", + "upload_file": "Augšupielādēt failu" + }, + "analytics_notice": "Piedaloties šajā beta versijā, jūs piekrītat anonīmu datu vākšanai, ko mēs izmantojam produkta uzlabošanai. Plašāku informāciju par to, kādus datus mēs izsekojam, varat atrast mūsu <2>konfidencialitātes politikā un mūsu <6>sīkfailu politikā.", + "app_selection_modal": { + "continue_in_browser": "Turpināt pārlūkprogrammā", + "open_in_app": "Atvērt lietotnē", + "text": "Gatavs pievienoties?", + "title": "Izvēlies lietotni" }, - "analytics_notice": "Piedalīšanās šajā beta apliecina piekrišanu anonīmu datu ievākšanai, ko mēs izmantojam, lai uzlabotu izstrādājumu. Vairāk informācijas par datiem, ko mēs ievācam, var atrast mūsu <2>privātuma nosacījumos un <5>sīkdatņu nosacījumos.", "call_ended_view": { "create_account_button": "Izveidot kontu", "create_account_prompt": "<0>Kādēļ nepabeigt ar paroles iestatīšanu, lai paturētu savu kontu?<1>Būs iespējams paturēt savu vārdu un iestatīt attēlu izmantošanai turpmākajos zvanos", @@ -23,31 +38,104 @@ "reconnect_button": "Atkārtoti savienoties", "survey_prompt": "Kā Tev veicās?" }, + "call_name": "Zvana nosaukums", "common": { + "analytics": "Analītika", "audio": "Skaņa", "avatar": "Attēls", + "back": "Atpakaļ", "display_name": "Attēlojamais vārds", + "encrypted": "Šifrēts", "home": "Sākums", "loading": "Lādējas…", + "next": "Nākamais", + "options": "Opcijas", "password": "Parole", + "preferences": "Iestatījumi", "profile": "Profils", + "reaction": "Reakcija", + "reactions": "Reakcijas", "settings": "Iestatījumi", - "username": "Lietotājvārds" + "unencrypted": "Nav šifrēts", + "username": "Lietotājvārds", + "video": "Video" + }, + "developer_mode": { + "crypto_version": "Crypto versija: {{version}}", + "debug_tile_layout_label": "Vietu izkārtojuma atkļūdošana", + "device_id": "Ierīces ID: {{id}}", + "duplicate_tiles_label": "Papildu vietu kopiju skaits vienam dalībniekam", + "environment_variables": "Vides mainīgie", + "hostname": "Saimniekdatora nosaukums: {{hostname}}", + "livekit_server_info": "LiveKit Server informācija", + "livekit_sfu": "LiveKit SFU: {{url}}", + "matrix_id": "Matrix ID: {{id}}", + "show_connection_stats": "Rādīt savienojuma statistiku", + "show_non_member_tiles": "Rādīt vietu medijiem no ne-dalībniekiem", + "url_params": "URL parametri", + "use_new_membership_manager": "Izmantojiet jauno zvana MembershipManager versiju" }, "disconnected_banner": "Ir zaudēts savienojums ar serveri.", + "error": { + "call_is_not_supported": "Zvans netiek atbalstīts", + "call_not_found": "Zvans nav atrasts", + "call_not_found_description": "<0>Šķiet, ka šī saite nepieder nevienam esošam zvaniem. Pārbaudiet, vai jums ir pareizā saite, vai <1> izveidojiet jaunu. ", + "connection_lost": "Savienojums zaudēts", + "connection_lost_description": "Jūs tikāt atvienots no zvana.", + "e2ee_unsupported": "Nesaderīgs pārlūks", + "e2ee_unsupported_description": "Jūsu tīmekļa pārlūkprogramma neatbalsta encrypted zvanus. Atbalstītās pārlūkprogrammas ir Chrome, Safari un Firefox 117+.", + "generic": "Kaut kas nogāja greizi", + "generic_description": "Atkļūdošanas žurnālu iesniegšana palīdzēs mums izsekot problēmu.", + "insufficient_capacity": "Nepietiekama jauda", + "insufficient_capacity_description": "Serveris ir sasniedzis maksimālo ietilpību, un jūs šobrīd nevarat pievienoties zvanam. Mēģiniet vēlreiz vēlāk vai sazinieties ar servera administratoru, ja problēma joprojām pastāv.", + "matrix_rtc_focus_missing": "Serveris nav konfigurēts darbam ar{{brand}}. Lūdzu, sazinieties ar sava servera administratoru (Domēns: {{domain}}, Kļūdas kods: {{ errorCode }}).", + "open_elsewhere": "Atvērts citā cilnē", + "open_elsewhere_description": "{{brand}} ir atvērts citā cilnē. Ja tas neizklausās pareizi, mēģiniet atkārtoti ielādēt lapu.", + "unexpected_ec_error": "Negaidīta kļūda (<0>kļūdas kods: <1> {{ errorCode }}). Lūdzu, sazinieties ar servera administratoru." + }, + "group_call_loader": { + "banned_body": "Jums ir liegta ieeja šajā istabā.", + "banned_heading": "Aizliegts", + "call_ended_body": "Jūs esat noņemts no zvana.", + "call_ended_heading": "Zvans beidzies", + "knock_reject_body": "Jūsu pieteikums pievienoties tika noraidīts.", + "knock_reject_heading": "Piekļuve liegta", + "reason": "Iemesls: {{reason}}" + }, + "hangup_button_label": "Beigt zvanu", "header_label": "Element Call sākums", + "header_participants_label": "Dalībnieki", + "invite_modal": { + "link_copied_toast": "Saite nokopēta", + "title": "Uzaicināt uz šo zvanu" + }, "join_existing_call_modal": { "join_button": "Jā, pievienoties zvanam", "text": "Šis zvans jau pastāv. Vai vēlies pievienoties?", "title": "Pievienoties esošam zvanam?" }, + "layout_grid_label": "Režģis", "layout_spotlight_label": "Starmešu gaisma", "lobby": { - "join_button": "Pievienoties zvanam" + "ask_to_join": "Pieprasīt pievienoties zvanam", + "join_as_guest": "Pievienojies kā viesis", + "join_button": "Pievienoties zvanam", + "leave_button": "Atpakaļ uz jaunākajiem", + "waiting_for_invite": "Pieprasījums nosūtīts! Gaida atļauju pievienoties..." }, + "log_in": "Pieslēgties", "logging_in": "Piesakās…", "login_auth_links": "<0>Izveidot kontu vai <2>Piekļūt kā viesim", + "login_auth_links_prompt": "Vēl neesi reģistrējies?", + "login_subheading": "Lai turpinātu uz Element", "login_title": "Pieteikties", + "microphone_off": "Mikrofons izslēgts", + "microphone_on": "Mikrofons ieslēgts", + "mute_microphone_button_label": "Izslēgt mikrofonu", + "participant_count_zero": "{{count, number}}", + "participant_count_one": "{{count, number}}", + "participant_count_other": "{{count, number}}", + "qr_code": "QR kods", "rageshake_button_error_caption": "Atkārtoti mēģināt žurnāla ierakstu nosūtīšanu", "rageshake_request_modal": { "body": "Citam lietotājam šajā zvanā ir sarežģījumi. Lai labāk atklātu šīs nepilnības, mēs gribētu iegūt atkļūdošanas žurnālu.", @@ -59,28 +147,76 @@ "rageshake_sent": "Paldies!", "recaptcha_dismissed": "ReCaptcha atmesta", "recaptcha_not_loaded": "ReCaptcha nav ielādēta", + "recaptcha_ssla_caption": "Šo vietni aizsargā ReCAPTCHA un tiek piemēroti Google <2>konfidencialitātes politika un <6>Pakalpojuma noteikumi.<9>Noklikšķinot uz \"Reģistrēties\", jūs piekrītat mūsu <12>Programmatūras un pakalpojumu licences līgumam (SSLA)", "register": { "passwords_must_match": "Parolēm ir jāsakrīt", "registering": "Reģistrē…" }, "register_auth_links": "<0>Jau ir konts?<1><0>Pieteikties vai <2>Piekļūt kā viesim", "register_confirm_password_label": "Apstiprināt paroli", + "register_heading": "Izveido kontu", "return_home_button": "Atgriezties sākuma ekrānā", + "room_auth_view_continue_button": "Turpināt", + "room_auth_view_ssla_caption": "Noklikšķinot uz “Pievienoties zvanam tūlīt”, jūs piekrītat mūsu <2> programmatūras un pakalpojumu licences līgumam (SSLA) ", "screenshare_button_label": "Kopīgot ekrānu", "settings": { + "audio_tab": { + "effect_volume_description": "Pielāgojiet skaļumu, kurā tiek atskaņotas reakcijas un paceltas rokas skaņas.", + "effect_volume_label": "Skaņas efektu skaļums" + }, "developer_tab_title": "Izstrādātājs", + "devices": { + "camera": "Kamera", + "camera_numbered": "Kamera {{n}}", + "default": "Noklusējums", + "default_named": "Noklusējums <2> ({{name}} )", + "microphone": "Mikrofons", + "microphone_numbered": "Mikrofons {{n}}", + "speaker": "Skaļrunis", + "speaker_numbered": "Skaļrunis {{n}}" + }, "feedback_tab_body": "Ja tiek piedzīvoti sarežģījumi vai vienkārši ir vēlme sniegt kādu atsauksmi, lūgums zemāk nosūtīt mums īsu aprakstu.", "feedback_tab_description_label": "Tava atsauksme", "feedback_tab_h4": "Iesniegt atsauksmi", "feedback_tab_send_logs_label": "Iekļaut atkļūdošanas žurnāla ierakstus", "feedback_tab_thank_you": "Paldies, mēs saņēmām atsauksmi!", "feedback_tab_title": "Atsauksmes", - "opt_in_description": "<0><1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās." + "opt_in_description": "<0><1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās.", + "preferences_tab": { + "developer_mode_label": "Izstrādātāja režīms", + "developer_mode_label_description": "Iespējot izstrādātāja režīmu un rādīt cilni izstrādātāja iestatījumi.", + "introduction": "Šeit varat konfigurēt papildu opcijas, lai uzlabotu pieredzi.", + "reactions_play_sound_description": "Atskaņojiet skaņas efektu, kad kāds sūta reakciju uz zvanu.", + "reactions_play_sound_label": "Atskaņojiet reakcijas skaņas", + "reactions_show_description": "Rādīt animāciju, kad kāds nosūta reakciju.", + "reactions_show_label": "Rādīt reakcijas", + "show_hand_raised_timer_description": "Rādīt taimeri, kad dalībnieks paceļ roku", + "show_hand_raised_timer_label": "Rādīt rokas pacelšanas ilgumu" + } }, + "star_rating_input_label_zero": "{{count}} zvaigznes", "star_rating_input_label_one": "{{count}} zvaigzne", "star_rating_input_label_other": "{{count}} zvaigznes", + "start_new_call": "Sākt jaunu zvanu", + "start_video_button_label": "Sākt video", + "stop_screenshare_button_label": "Kopīgo ekrānu", + "stop_video_button_label": "Apturēt video", "submitting": "Iesniedz…", + "switch_camera": "Pārslēgt kameru", "unauthenticated_view_body": "Vēl neesi reģistrējies? <2>Izveidot kontu", "unauthenticated_view_login_button": "Pieteikties kontā", - "version": "Versija: {{version}}" + "unauthenticated_view_ssla_caption": "Noklikšķinot uz \"Aiziet\", jūs piekrītat mūsu <2>Programmatūras un pakalpojumu licences līgumam (SSLA)", + "unmute_microphone_button_label": "Ieslēgt mikrofonu", + "version": "{{productName}} versija: {{version}}", + "video_tile": { + "always_show": "Vienmēr rādīt", + "camera_starting": "Video ielāde...", + "change_fit_contain": "Pielāgot rāmim", + "collapse": "Sakļaut", + "expand": "Izvērst", + "mute_for_me": "Klusums man", + "muted_for_me": "Man izslēgts", + "volume": "Skaļums", + "waiting_for_media": "Gaida medijus..." + } } diff --git a/locales/ro/app.json b/locales/ro/app.json index 61bb3e44..ceaa79b7 100644 --- a/locales/ro/app.json +++ b/locales/ro/app.json @@ -4,20 +4,20 @@ }, "action": { "close": "Închide", - "copy_link": "Copiază linkul", + "copy_link": "Copiaţi linkul", "edit": "Editare", "go": "Du-te", "invite": "Invită", "lower_hand": "Mâna inferioară", - "no": "No", + "no": "Nu", "pick_reaction": "Alegeți reacția", "raise_hand": "Ridicați mâna", - "register": "Inregistrare", + "register": "Creaţi un cont", "remove": "elimina", "show_less": "Arată mai puțin", "show_more": "Arată mai mult", "sign_in": "Autentificare", - "sign_out": "Sign out", + "sign_out": "Deconecta-ţi-vă", "submit": "Trimiteți", "upload_file": "Încărcați fișierul" }, @@ -26,27 +26,27 @@ "continue_in_browser": "Continuați în browser", "open_in_app": "Deschideți în aplicație", "text": "Sunteți gata să vă alăturați?", - "title": "Selectați aplicația" + "title": "Selectați o aplicație" }, "call_ended_view": { - "create_account_button": "Creează cont", + "create_account_button": "Creaţi un cont", "create_account_prompt": "<0>De ce să nu terminați prin configurarea unei parole pentru a vă păstra contul? <1>Veți putea să vă păstrați numele și să setați un avatar pentru a fi utilizat la apelurile viitoare ", "feedback_done": "<0>Vă mulțumim pentru feedback! ", "feedback_prompt": "<0>Ne-ar plăcea să auzim feedback-ul dvs., astfel încât să vă putem îmbunătăți experiența. ", "headline": "{{displayName}}, apelul tău s-a încheiat.", - "not_now_button": "Nu acum, reveniți la ecranul de pornire", - "reconnect_button": "Reconecta", - "survey_prompt": "Cum a mers?" + "not_now_button": "Nu acum, reveniți la ecranul principal", + "reconnect_button": "Reconectaţi-vă", + "survey_prompt": "Cum a fost?" }, "call_name": "Numele apelului", "common": { "analytics": "Analiză", "audio": "Audio", - "avatar": "avatar", + "avatar": "Imaginea de profil", "back": "Înapoi", "display_name": "Nume afișat", "encrypted": "Criptat", - "home": "Acasa", + "home": "Acasă", "loading": "Se încarcă...", "next": "Urmator\n", "options": "Opțiuni", @@ -55,19 +55,43 @@ "profile": "Profil", "reaction": "Reacție", "reactions": "Reacții", - "settings": "Settings", + "settings": "Setări", "unencrypted": "Nu este criptat", - "username": "Nume utilizator", - "video": "Videoclip" + "username": "Numele utilizatorului", + "video": "Video" }, "developer_mode": { "crypto_version": "Versiunea Crypto: {{version}}", + "debug_tile_layout_label": "Depanaţi aranjamentul cartonaşelor", "device_id": "ID-ul dispozitivului: {{id}}", "duplicate_tiles_label": "Numărul de exemplare suplimentare de cartonașe per participant", + "environment_variables": "Variabile de mediu", "hostname": "Numele gazdei: {{hostname}}", - "matrix_id": "ID-ul matricei: {{id}}" + "matrix_id": "ID-ul matricei: {{id}}", + "show_connection_stats": "Afişaţi informaţii cu privire la starea conexiunii", + "show_non_member_tiles": "Afişaţi pictograme pentru fluxul media care nu aparţine participanţilor apelului", + "url_params": "Parametrii linkului", + "use_new_membership_manager": "Folosiţi noua versiune de administrator pentru participanţi ai apelului", + "use_to_device_key_transport": "Folosiţi metoda de transport direct către dispozitiv. Aceasta va reveni la transportul prin intermediul evenimentelor din cameră doar dacă un alt participant la apel recurge la acel mod de transport mai întâi." + }, + "disconnected_banner": "Conexiunea către server s-a încheiat abrupt", + "error": { + "call_is_not_supported": "Acest tip de apel nu este suportat", + "call_not_found": "Apelul nu a fost găsit", + "call_not_found_description": "<0>Acel link nu pare să aparţină unui apel existent. Verificaţi dacă aţi introdus linkul corect, sau <1>creaţi unul nou.", + "connection_lost": "Conexiunea s-a pierdut", + "connection_lost_description": "Aţi fost deconectat/ă de la apel", + "e2ee_unsupported": "Navigatorul dumneavoastră este incompatibil cu criptarea integrală", + "e2ee_unsupported_description": "Navigatorul/browserul dumneavoastră web nu suportă apeluri în conversaţii cu criptare integrală. Printre navigatoarele suportate, se numără Chrome, Safari şi Firefox 117+.", + "generic": "A apărut o eroare neaşteptată", + "generic_description": "Dacă ne trimiteţi jurnalele de depanare generate de aplicaţie, ne puteţi ajuta să rezolvăm problema.", + "insufficient_capacity": "Capacitate insuficientă", + "insufficient_capacity_description": "Serverul a ajuns la capacitatea maximă și nu vă puteți alătura apelului în acest moment. Încercați din nou in câteva minute, sau contactați administratorul serverului dumneavoastră dacă problema persistă.", + "matrix_rtc_focus_missing": "Serverul nu este configurat să funcționeze cu{{brand}}. Vă rugăm să contactați administratorul serverului dumneavoastră pentru a raporta o eroare în configurare. Detalii: Domeniu: {{domain}}. Cod de eroare: {{ errorCode }}.", + "open_elsewhere": "Aplicaţia este deschisă intr-o altă pagină", + "open_elsewhere_description": "{{brand}} a fost deschis într-o altă pagină. Dacă credeți că acest mesaj a fost emis in eroare, încercați să reîncărcați pagina.", + "unexpected_ec_error": "A apărut o eroare neașteptată (Cod de <0> eroare: <1> {{ errorCode }}). Vă rugăm să contactați administratorul serverului dumneavoastră." }, - "disconnected_banner": "Conectivitatea la server a fost pierdută.", "group_call_loader": { "banned_body": "Ai fost interzis să ieși din cameră.", "banned_heading": "Interzis", @@ -135,6 +159,10 @@ "effect_volume_label": "Volumul efectului sonor" }, "developer_tab_title": "dezvoltator", + "devices": { + "microphone": "Microfon", + "speaker": "Difuzor" + }, "feedback_tab_body": "Dacă întâmpinați probleme sau pur și simplu doriți să oferiți feedback, vă rugăm să ne trimiteți o scurtă descriere mai jos.", "feedback_tab_description_label": "Feedback-ul tău", "feedback_tab_h4": "Trimiteți Feedback", @@ -143,6 +171,8 @@ "feedback_tab_title": "Feedback", "opt_in_description": "<0><1>Puteți retrage consimțământul debifând această casetă. Dacă sunteți în prezent la un apel, această setare va intra în vigoare la sfârșitul apelului.", "preferences_tab": { + "developer_mode_label": "Modul dezvoltator", + "developer_mode_label_description": "Activați modul dezvoltator și afișați pagina specifică setărilor pentru dezvoltatori", "introduction": "Aici puteți configura opțiuni suplimentare pentru o experiență îmbunătățită.", "reactions_play_sound_description": "Redați un efect sonor atunci când cineva trimite o reacție la un apel.", "reactions_play_sound_label": "Redați sunete de reacție", @@ -164,11 +194,13 @@ "version": "{{productName}}Versiune: {{version}}", "video_tile": { "always_show": "Arată întotdeauna", + "camera_starting": "Se încarcă fluxul video...", "change_fit_contain": "Se potrivește cadrului", "collapse": "colaps", "expand": "Extindeți", "mute_for_me": "Mute pentru mine", "muted_for_me": "Dezactivat pentru mine", - "volume": "VOLUM" + "volume": "VOLUM", + "waiting_for_media": "Flux multimedia în aşteptare" } } diff --git a/locales/ru/app.json b/locales/ru/app.json index cdfdfe80..b6ec8a6a 100644 --- a/locales/ru/app.json +++ b/locales/ru/app.json @@ -21,7 +21,7 @@ "submit": "Отправить", "upload_file": "Загрузить файл" }, - "analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности и нашей <5> Политике использования файлов cookie.", + "analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Дополнительную информацию о том, какие данные мы отслеживаем, можно найти в нашей <2> Политике конфиденциальности и Политике <6> использования файлов cookie.", "app_selection_modal": { "continue_in_browser": "Продолжить в браузере", "open_in_app": "Открыть в приложении", @@ -65,13 +65,16 @@ "debug_tile_layout_label": "Отладка расположения плиток", "device_id": "Идентификатор устройства: {{id}}", "duplicate_tiles_label": "Количество дополнительных копий плиток на участника", + "environment_variables": "Переменные окружения", "hostname": "Имя хоста: {{hostname}}", "livekit_server_info": "Информация о сервере LiveKit", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrix ID: {{id}}", "show_connection_stats": "Показать статистику подключений", "show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками", - "use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager" + "url_params": "Параметры URL-адреса", + "use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager", + "use_to_device_key_transport": "Используйте для передачи ключей устройства. Это позволит вернуться к передаче ключей комнаты, когда другой участник вызова отправит ключ комнаты" }, "disconnected_banner": "Связь с сервером была потеряна.", "error": { @@ -98,7 +101,7 @@ "call_ended_heading": "Вызов завершен", "knock_reject_body": "Участники комнаты отклонили ваш запрос на присоединение.", "knock_reject_heading": "Не разрешено присоединиться", - "reason": "Причина" + "reason": "Причина: {{reason}}" }, "hangup_button_label": "Завершить звонок", "header_label": "Главная Element Call", @@ -145,6 +148,7 @@ "rageshake_sent": "Спасибо!", "recaptcha_dismissed": "Проверка не пройдена", "recaptcha_not_loaded": "Невозможно начать проверку", + "recaptcha_ssla_caption": "Этот сайт защищен reCAPTCHA, и к нему применяются <2> Политика конфиденциальности и <6> Условия обслуживания Google. <9>Нажимая «Зарегистрироваться», вы соглашаетесь с нашим лицензионным соглашением на <12> программное обеспечение и услуги (SSLA) ", "register": { "passwords_must_match": "Пароли должны совпадать", "registering": "Регистрация…" @@ -154,12 +158,16 @@ "register_heading": "Создать учетную запись", "return_home_button": "Вернуться в Начало", "room_auth_view_continue_button": "Продолжить", + "room_auth_view_ssla_caption": "Нажимая кнопку \"Присоединиться к звонку\", вы соглашаетесь с нашим <2>Лицензионное соглашение на программное обеспечение и услуги (SSLA)", "screenshare_button_label": "Поделиться экраном", "settings": { "audio_tab": { "effect_volume_description": "Отрегулируйте громкость воспроизведения реакций и эффектов поднятия руки.", "effect_volume_label": "Громкость звукового эффекта" }, + "background_blur_header": "Фон", + "background_blur_label": "Размытие фона видео", + "blur_not_supported_by_browser": "(Размытие фона не поддерживается этим устройством.)", "developer_tab_title": "Разработчику", "devices": { "camera": "Камера", @@ -190,8 +198,9 @@ "show_hand_raised_timer_label": "Показать продолжительность поднятия руки" } }, - "star_rating_input_label_one": "{{count}} отмечен", - "star_rating_input_label_other": "{{count}} отмеченных", + "star_rating_input_label_one": "{{count}} звезда", + "star_rating_input_label_few": "{{count}} звезды", + "star_rating_input_label_many": "{{count}} звезд", "start_new_call": "Начать новый звонок", "start_video_button_label": "Начать видео", "stop_screenshare_button_label": "Демонстрация экрана", @@ -200,8 +209,9 @@ "switch_camera": "Переключить камеру", "unauthenticated_view_body": "Ещё не зарегистрированы? <2>Создайте аккаунт", "unauthenticated_view_login_button": "Войдите в свой аккаунт", + "unauthenticated_view_ssla_caption": "Нажимая «Перейти», вы соглашаетесь с нашим лицензионным соглашением на <2> программное обеспечение и услуги (SSLA) ", "unmute_microphone_button_label": "Включить микрофон", - "version": "Версия: {{version}}", + "version": "{{productName}} версия: {{version}}", "video_tile": { "always_show": "Показывать всегда", "camera_starting": "Загрузка видео...", diff --git a/locales/sk/app.json b/locales/sk/app.json index 0c320dfd..20e41408 100644 --- a/locales/sk/app.json +++ b/locales/sk/app.json @@ -21,7 +21,7 @@ "submit": "Odoslať", "upload_file": "Nahrať súbor" }, - "analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov a <5>Zásadách používania súborov cookie.", + "analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov a <6>Zásadách používania súborov cookie.", "app_selection_modal": { "continue_in_browser": "Pokračovať v prehliadači", "open_in_app": "Otvoriť v aplikácii", @@ -65,13 +65,17 @@ "debug_tile_layout_label": "Ladenie rozloženia dlaždíc", "device_id": "ID zariadenia: {{id}}", "duplicate_tiles_label": "Počet ďalších kópií dlaždíc na účastníka", + "environment_variables": "Premenné prostredia", "hostname": "Názov hostiteľa: {{hostname}}", "livekit_server_info": "Informácie o serveri LiveKit", "livekit_sfu": "LiveKit SFU: {{url}}", "matrix_id": "Matrix ID: {{id}}", + "mute_all_audio": "Stlmiť všetky zvuky (účastníkov, reakcií, zvuky pripojenia)", "show_connection_stats": "Zobraziť štatistiky pripojenia", "show_non_member_tiles": "Zobraziť dlaždice pre nečlenské médiá", - "use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager" + "url_params": "Parametre URL adresy", + "use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager", + "use_to_device_key_transport": "Používa sa na prenos kľúča zariadenia. Toto sa vráti k prenosu kľúča miestnosti, keď iný účastník hovoru poslal kľúč od miestnosti" }, "disconnected_banner": "Spojenie so serverom sa stratilo.", "error": { @@ -145,6 +149,7 @@ "rageshake_sent": "Ďakujeme!", "recaptcha_dismissed": "Recaptcha zamietnutá", "recaptcha_not_loaded": "Recaptcha sa nenačítala", + "recaptcha_ssla_caption": "Táto stránka je chránená reCAPTCHA a platia <2>Zásady ochrany osobných údajov a <6>Podmienky služby spoločnosti Google. <9>Kliknutím na tlačidlo „Registrovať“ súhlasíte s našou <12>Licenčnou zmluvou o softvéri a službách (SSLA)", "register": { "passwords_must_match": "Heslá sa musia zhodovať", "registering": "Registrácia…" @@ -154,12 +159,16 @@ "register_heading": "Vytvorte si účet", "return_home_button": "Návrat na domovskú obrazovku", "room_auth_view_continue_button": "Pokračovať", + "room_auth_view_ssla_caption": "Kliknutím na „Pripojiť sa k hovoru teraz“ súhlasíte s našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)", "screenshare_button_label": "Zdieľať obrazovku", "settings": { "audio_tab": { "effect_volume_description": "Upraviť hlasitosť, pri ktorej sa prehrávajú reakcie a efekty zdvihnutia ruky.", "effect_volume_label": "Hlasitosť zvukového efektu" }, + "background_blur_header": "Pozadie", + "background_blur_label": "Rozmazať pozadie videa", + "blur_not_supported_by_browser": "(Rozmazanie pozadia nie je podporované týmto zariadením.)", "developer_tab_title": "Vývojár", "devices": { "camera": "Kamera", @@ -191,6 +200,7 @@ } }, "star_rating_input_label_one": "{{count}} hviezdička", + "star_rating_input_label_few": "{{count}} hviezdičky", "star_rating_input_label_other": "{{count}} hviezdičiek", "start_new_call": "Spustiť nový hovor", "start_video_button_label": "Spustiť video", @@ -200,8 +210,9 @@ "switch_camera": "Prepnúť fotoaparát", "unauthenticated_view_body": "Ešte nie ste zaregistrovaný? <2>Vytvorte si účet", "unauthenticated_view_login_button": "Prihláste sa do svojho konta", + "unauthenticated_view_ssla_caption": "Kliknutím na tlačidlo „Prejsť“ súhlasítes našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)", "unmute_microphone_button_label": "Zrušiť stlmenie mikrofónu", - "version": "Verzia: {{version}}", + "version": "Verzia {{productName}}: {{version}}", "video_tile": { "always_show": "Vždy zobraziť", "camera_starting": "Načítavanie videa...", diff --git a/locales/sv/app.json b/locales/sv/app.json index efa3036b..500c4ee7 100644 --- a/locales/sv/app.json +++ b/locales/sv/app.json @@ -73,7 +73,8 @@ "show_connection_stats": "Visa anslutningsstatistik", "show_non_member_tiles": "Visa paneler för media som inte är medlemmar", "url_params": "URL-parametrar", - "use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager" + "use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager", + "use_to_device_key_transport": "Använd \"till enhet\"-nyckeltransport. Detta kommer att falla tillbaka till rumsnyckeltransport om en annan samtalsmedlem skickar en rumsnyckel." }, "disconnected_banner": "Anslutningen till servern har brutits.", "error": { diff --git a/locales/uk/app.json b/locales/uk/app.json index 717e8df3..d7d1b0ea 100644 --- a/locales/uk/app.json +++ b/locales/uk/app.json @@ -21,7 +21,7 @@ "submit": "Надіслати", "upload_file": "Завантажити файл" }, - "analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності і нашій <5>Політиці про куки.", + "analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності і нашій <6>Політиці про файли cookie.", "app_selection_modal": { "continue_in_browser": "Продовжити у браузері", "open_in_app": "Відкрити у застосунку", @@ -194,8 +194,9 @@ "show_hand_raised_timer_label": "Показувати тривалість підняття руки" } }, - "star_rating_input_label_one": "{{count}} зірок", - "star_rating_input_label_other": "{{count}} зірок", + "star_rating_input_label_one": "{{count}} зірка", + "star_rating_input_label_few": "{{count}} зірки", + "star_rating_input_label_many": "{{count}} зірок", "start_new_call": "Розпочати новий виклик", "start_video_button_label": "Розпочати відео", "stop_screenshare_button_label": "Презентація екрана", @@ -206,7 +207,7 @@ "unauthenticated_view_login_button": "Увійдіть до свого облікового запису", "unauthenticated_view_ssla_caption": "Натискаючи \"Перейти\", ви погоджуєтеся з нашою <2>Ліцензійною угодою на програмне забезпечення та послуги (SSLA) ", "unmute_microphone_button_label": "Увімкнути мікрофон", - "version": "Версія: {{version}}", + "version": "{{productName}} версія: {{version}}", "video_tile": { "always_show": "Показувати завжди", "camera_starting": "Завантаження відео...", diff --git a/locales/zh-Hans/app.json b/locales/zh-Hans/app.json index f2433e16..9c0e7c8f 100644 --- a/locales/zh-Hans/app.json +++ b/locales/zh-Hans/app.json @@ -4,7 +4,9 @@ }, "action": { "close": "关闭", + "copy_link": "复制链接", "go": "开始", + "invite": "邀请", "no": "否", "register": "注册", "remove": "移除", @@ -31,12 +33,14 @@ }, "call_name": "通话名称", "common": { + "analytics": "分析", "audio": "音频", "avatar": "头像", "display_name": "显示名称", "encrypted": "已加密", "home": "主页", "loading": "加载中……", + "options": "选项", "password": "密码", "profile": "个人信息", "settings": "设置", @@ -45,8 +49,20 @@ "video": "视频" }, "disconnected_banner": "与服务器的连接中断。", + "group_call_loader": { + "banned_body": "你已被房间封禁", + "banned_heading": "已被封禁", + "call_ended_body": "你已被移出通话", + "call_ended_heading": "通话结束", + "knock_reject_body": "房间成员拒绝了你的加入请求" + }, "hangup_button_label": "通话结束", "header_label": "Element Call主页", + "header_participants_label": "参与者", + "invite_modal": { + "link_copied_toast": "链接已复制到剪贴板", + "title": "邀请参加此次通话" + }, "join_existing_call_modal": { "join_button": "是,加入通话", "text": "该通话已存在,你想加入吗?", @@ -58,12 +74,16 @@ "join_button": "加入通话", "leave_button": "返回最近通话" }, + "log_in": "登录", "logging_in": "登录中……", "login_auth_links": "<0>创建账户 Or <2>以访客身份继续", + "login_auth_links_prompt": "还未注册?", + "login_subheading": "继续使用 Element", "login_title": "登录", "microphone_off": "麦克风关闭", "microphone_on": "麦克风开启", "mute_microphone_button_label": "静音麦克风", + "participant_count_other": "{{count, number}}", "rageshake_button_error_caption": "重传日志", "rageshake_request_modal": { "body": "这个通话中的另一个用户出现了问题。为了更好地诊断这些问题,我们想收集调试日志。", @@ -81,6 +101,7 @@ }, "register_auth_links": "<0>已有账户?<1><0>登录 Or <2>以访客身份继续", "register_confirm_password_label": "确认密码", + "register_heading": "创建您的账户", "return_home_button": "返回主页", "screenshare_button_label": "屏幕共享", "settings": { @@ -103,5 +124,10 @@ "unauthenticated_view_body": "还没有注册? <2>创建账户<2>", "unauthenticated_view_login_button": "登录你的账户", "unmute_microphone_button_label": "取消麦克风静音", - "version": "版本:{{version}}" + "version": "版本:{{version}}", + "video_tile": { + "change_fit_contain": "贴合框架", + "mute_for_me": "为我静音", + "volume": "音量" + } } From da2ce683483ca46c1fd8c102186b00479c8487b2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 May 2025 23:01:56 +0000 Subject: [PATCH 5/5] Update GitHub Actions --- .github/workflows/build-and-publish-docker.yaml | 4 ++-- .github/workflows/build-element-call.yaml | 2 +- .github/workflows/changelog-label.yml | 2 +- .github/workflows/deploy-to-netlify.yaml | 2 +- .github/workflows/lint.yaml | 2 +- .github/workflows/publish-embedded-packages.yaml | 16 ++++++++-------- .github/workflows/publish.yaml | 6 +++--- .github/workflows/test.yaml | 6 +++--- .github/workflows/translations-download.yaml | 2 +- 9 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-and-publish-docker.yaml b/.github/workflows/build-and-publish-docker.yaml index fbe2d665..3ebb594a 100644 --- a/.github/workflows/build-and-publish-docker.yaml +++ b/.github/workflows/build-and-publish-docker.yaml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: 📥 Download artifact - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ inputs.artifact_run_id }} @@ -53,7 +53,7 @@ jobs: uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Build and push Docker image - uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0 + uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/build-element-call.yaml b/.github/workflows/build-element-call.yaml index 5a193eb5..fc5eee02 100644 --- a/.github/workflows/build-element-call.yaml +++ b/.github/workflows/build-element-call.yaml @@ -31,7 +31,7 @@ jobs: - name: Enable Corepack run: corepack enable - name: Yarn cache - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version-file: ".node-version" diff --git a/.github/workflows/changelog-label.yml b/.github/workflows/changelog-label.yml index 6fdfd1e6..d8a82832 100644 --- a/.github/workflows/changelog-label.yml +++ b/.github/workflows/changelog-label.yml @@ -7,7 +7,7 @@ jobs: pr-changelog-label: runs-on: ubuntu-latest steps: - - uses: yogevbd/enforce-label-action@2.1.0 + - uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2 with: REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies" REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label" diff --git a/.github/workflows/deploy-to-netlify.yaml b/.github/workflows/deploy-to-netlify.yaml index 53a6931b..388192e4 100644 --- a/.github/workflows/deploy-to-netlify.yaml +++ b/.github/workflows/deploy-to-netlify.yaml @@ -46,7 +46,7 @@ jobs: Exercise caution. Use test accounts. - name: 📥 Download artifact - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.ELEMENT_BOT_TOKEN }} run-id: ${{ inputs.artifact_run_id }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 1049930e..0efbcf5a 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -11,7 +11,7 @@ jobs: - name: Enable Corepack run: corepack enable - name: Yarn cache - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version-file: ".node-version" diff --git a/.github/workflows/publish-embedded-packages.yaml b/.github/workflows/publish-embedded-packages.yaml index b09b3376..c309c91c 100644 --- a/.github/workflows/publish-embedded-packages.yaml +++ b/.github/workflows/publish-embedded-packages.yaml @@ -71,7 +71,7 @@ jobs: - name: Determine filename run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV" - name: 📥 Download built element-call artifact - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id || github.run_id }} @@ -83,7 +83,7 @@ jobs: run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 - name: Upload if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2 + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 with: files: | ${{ env.FILENAME_PREFIX }}.tar.gz @@ -104,7 +104,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: 📥 Download built element-call artifact - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id || github.run_id }} @@ -113,7 +113,7 @@ jobs: # n.b. We don't enable corepack here because we are using plain npm - name: Setup node - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version-file: .node-version registry-url: "https://registry.npmjs.org" @@ -145,7 +145,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: 📥 Download built element-call artifact - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id || github.run_id }} @@ -153,7 +153,7 @@ jobs: path: embedded/android/lib/src/main/assets/element-call - name: ☕️ Setup Java - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4 with: distribution: "temurin" java-version: "17" @@ -200,7 +200,7 @@ jobs: path: element-call - name: 📥 Download built element-call artifact - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id || github.run_id }} @@ -260,7 +260,7 @@ jobs: echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}" - name: Add release notes if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }} - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2 + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 with: append_body: true body: | diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 463953fa..86169e16 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -31,7 +31,7 @@ jobs: - name: Determine filename run: echo "FILENAME_PREFIX=element-call-${VERSION:1}" >> "$GITHUB_ENV" - name: 📥 Download built element-call artifact - uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id || github.run_id }} @@ -42,7 +42,7 @@ jobs: - name: Create Checksum run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256 - name: Upload - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2 + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 with: files: | ${{ env.FILENAME_PREFIX }}.tar.gz @@ -68,7 +68,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Add release note - uses: softprops/action-gh-release@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2 + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2 with: append_body: true body: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 85215e68..2ef4dc04 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -13,7 +13,7 @@ jobs: - name: Enable Corepack run: corepack enable - name: Yarn cache - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version-file: ".node-version" @@ -22,7 +22,7 @@ jobs: - name: Vitest run: "yarn run test:coverage" - name: Upload to codecov - uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Enable Corepack run: corepack enable - - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version-file: ".node-version" diff --git a/.github/workflows/translations-download.yaml b/.github/workflows/translations-download.yaml index ae1e92d0..fc4fbf40 100644 --- a/.github/workflows/translations-download.yaml +++ b/.github/workflows/translations-download.yaml @@ -18,7 +18,7 @@ jobs: - name: Enable Corepack run: corepack enable - - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: cache: "yarn" node-version-file: ".node-version"