Compare commits

..
Author SHA1 Message Date
Robin 1d7e2288f7 Fix call.element.dev not updating after branch rename 2026-09-09 08:49:08 +02:00
9 changed files with 1482 additions and 1597 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
> Every PR must have a linked issue
> that a maintainer has reviewed and approved **before you started writing code**.
> PRs that don't meet this requirement will not be reviewed.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) for ElementCall decided for this approach.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) for ElementCall decided for this approach.
## Content
@@ -39,7 +39,7 @@ Uncomment the markdown table below and fill in the last line:
## Checklist
- [ ] A linked, pre-approved issue exists for this feature or UI change.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) in full.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) in full.
- [ ] Pull request includes screenshots or videos for any UI changes.
- [ ] Tests written for new code (and existing touched code where feasible).
- [ ] Linter and other CI checks pass.
+5 -5
View File
@@ -6,7 +6,7 @@ on:
- opened
- labeled
push:
branches: [livekit, full-mesh]
branches: [main]
jobs:
build_full_element_call:
# Use the full package vite build
@@ -22,8 +22,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
deploy_develop:
# Deploy livekit branch to call.element.dev after build completes
if: github.ref == 'refs/heads/livekit'
# Deploy main branch to call.element.dev after build completes
if: github.ref == 'refs/heads/main'
needs: build_full_element_call
runs-on: ubuntu-latest
steps:
@@ -43,8 +43,8 @@ jobs:
}
})
docker_for_develop:
# Build docker and publish docker for livekit branch after build completes
if: github.ref == 'refs/heads/livekit'
# Build docker and publish docker for main branch after build completes
if: github.ref == 'refs/heads/main'
needs: build_full_element_call
permissions:
contents: write
-11
View File
@@ -39,17 +39,6 @@
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"react/display-name": "error",
// TODO: These React Compiler-based rules joined oxlint's correctness
// category in v1.81 and flag many pre-existing patterns, including hooks
// like useLatest/useInitial/useReactiveState that intentionally access
// refs during render. Re-evaluate them when adopting the React Compiler.
"react/immutability": "off",
"react/no-deriving-state-in-effects": "off",
"react/preserve-manual-memoization": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/static-components": "off",
"react/use-memo": "off",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
+2 -2
View File
@@ -81,7 +81,7 @@
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.11",
"@vitest/ui": "4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
@@ -101,7 +101,7 @@
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.66.0",
"oxfmt": "^0.61.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0",
"pako": "^2.0.4",
+1458 -1450
View File
File diff suppressed because it is too large Load Diff
+1 -61
View File
@@ -10,7 +10,6 @@ import * as ComponentsCore from "@livekit/components-core";
import { ObservableScope } from "./ObservableScope";
import { AudioOutput } from "./MediaDevices";
import { audioOutput as audioOutputSetting } from "../settings/settings";
import { withTestScheduler } from "../utils/test";
const BT_SPEAKER = {
@@ -94,8 +93,6 @@ describe("AudioOutput Tests", () => {
beforeEach(() => {
testScope = new ObservableScope();
// Device preferences persist in localStorage across tests
audioOutputSetting.setValue(undefined);
});
afterEach(() => {
@@ -153,9 +150,7 @@ describe("AudioOutput Tests", () => {
expectObservable(audioOutput.selected$).toBe("abcde", {
a: undefined,
// No "default" pseudo-device (Firefox): the virtual browser default is
// selected until the user picks something.
b: { id: "", virtualEarpiece: false },
b: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
c: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
d: { id: LAPTOP_SPEAKER.deviceId, virtualEarpiece: false },
e: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
@@ -163,61 +158,6 @@ describe("AudioOutput Tests", () => {
});
});
it("falls back to the browser default when the selected device disappears", () => {
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a---b", {
a: DEVICE_LIST_B,
// The monitor is unplugged (or a Bluetooth sink changes profile)
b: [LAPTOP_SPEAKER],
}),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
schedule("--a", {
a: () => audioOutput.select(MONITOR_SPEAKER.deviceId),
});
expectObservable(audioOutput.selected$).toBe("a-b-c", {
a: { id: "", virtualEarpiece: false },
b: { id: MONITOR_SPEAKER.deviceId, virtualEarpiece: false },
// Rather than pinning some other physical device, let the browser route
c: { id: "", virtualEarpiece: false },
});
});
});
it("lists the virtual browser default first when there is no default pseudo-device", () => {
withTestScheduler(({ behavior, cold, expectObservable }) => {
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
cold("a", { a: DEVICE_LIST_B }),
);
const audioOutput = new AudioOutput(
behavior("a", { a: true }),
testScope,
);
expectObservable(audioOutput.available$).toBe("a", {
a: new Map([
["", { type: "default", name: null }],
[
LAPTOP_SPEAKER.deviceId,
{ type: "name", name: LAPTOP_SPEAKER.label },
],
[
MONITOR_SPEAKER.deviceId,
{ type: "name", name: MONITOR_SPEAKER.label },
],
]),
});
});
});
it("Test mappings", () => {
// In a real life setup there would be first a blanked list
// then the real one.
@@ -516,12 +516,11 @@ describe("remote track logging", () => {
it("logs remote participant and track events on the connection logger", () => {
setupTest();
const info = vi.fn();
const warn = vi.fn();
const testLogger = {
getChild: (): unknown => testLogger,
info,
debug: vi.fn(),
warn,
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
new Connection(
@@ -546,7 +545,6 @@ describe("remote track logging", () => {
source: "microphone",
trackSid: "TR_mic",
isMuted: false,
isEncrypted: true,
} as unknown as RemoteTrackPublication;
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
@@ -566,27 +564,11 @@ describe("remote track logging", () => {
);
expect(messages()).toEqual([
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111 encrypted=true",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111",
]);
// Encryption status changes are logged; cryptor errors are warnings
fakeLivekitRoom.emit(
RoomEvent.ParticipantEncryptionStatusChanged,
false,
bob,
);
expect(messages().at(-1)).toBe(
"Encryption status of @bob:example.org:DEV111: encrypted=false",
);
const cryptorError = new Error("missing key at index 3");
fakeLivekitRoom.emit(RoomEvent.EncryptionError, cryptorError, bob);
expect(warn).toHaveBeenCalledWith(
"Encryption error for @bob:example.org:DEV111:",
cryptorError,
);
// Local mute events are already logged by the Publisher
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
...fakeLocalParticipant,
@@ -186,11 +186,8 @@ export class Connection {
private logRemoteTrackEvents(): void {
const room = this.livekitRoom;
const log = this.logger.getChild("[RemoteTracks]");
// The encryption flag matters: if the publisher encrypts but this client
// believes the track is unencrypted, the cryptor is bypassed and raw
// ciphertext reaches the decoder (audible as loud noise bursts).
const track = (pub: TrackPublication, p: Participant): string =>
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity} encrypted=${pub.isEncrypted}`;
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity}`;
const onParticipantConnected = (p: RemoteParticipant): void =>
log.info(`Participant connected: ${p.identity} (${p.sid})`);
@@ -235,20 +232,6 @@ export class Connection {
state: Track.StreamState,
p: RemoteParticipant,
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
const onEncryptionStatusChanged = (
encrypted: boolean,
p?: Participant,
): void =>
log.info(
`Encryption status of ${p?.identity ?? "unknown participant"}: encrypted=${encrypted}`,
);
// livekit-client throttles these per cryptor; they indicate frames being
// dropped (missing/invalid key).
const onEncryptionError = (error: Error, p?: Participant): void =>
log.warn(
`Encryption error for ${p?.identity ?? "unknown participant"}:`,
error,
);
room
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
@@ -260,12 +243,7 @@ export class Connection {
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.on(RoomEvent.TrackMuted, onTrackMuted)
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.on(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.on(RoomEvent.EncryptionError, onEncryptionError);
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
this.scope.onEnd(() => {
room
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
@@ -277,12 +255,7 @@ export class Connection {
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.off(RoomEvent.TrackMuted, onTrackMuted)
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged)
.off(
RoomEvent.ParticipantEncryptionStatusChanged,
onEncryptionStatusChanged,
)
.off(RoomEvent.EncryptionError, onEncryptionError);
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
});
}
+7 -14
View File
@@ -257,21 +257,14 @@ export class AudioOutput implements MediaDevice<
map((availableRaw) => {
let available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw);
// Create a virtual default audio output for browsers that don't have one
// (Firefox, Safari). Its device ID must be the empty string because
// that's what setSinkId recognizes. It goes first so that it is the
// fallback when no output has been explicitly chosen (or the chosen
// one disappears), rather than pinning the first physical device
// with setSinkId: pinned sinks are not re-routed by the browser, and
// Firefox leaves the audio elements silent when a pinned sink goes
// away (e.g. a Bluetooth headset switching profile when its
// microphone is opened). We can't know which physical device the
// browser default resolves to, so the entry carries no name.
// 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.
if (available.size && !available.has("") && !available.has("default"))
available = new Map<string, AudioOutputDeviceLabel>([
["", { type: "default", name: null }],
...available,
]);
available.set("", {
type: "default",
name: availableRaw[0]?.label || null,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
if (isSafari) {