Merge branch 'livekit' into voip-team/rebased-multiSFU
This commit is contained in:
@@ -283,6 +283,7 @@ self-hosters and developers working with Element Call.
|
|||||||
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
||||||
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
||||||
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
||||||
|
- [MatrixRTC Back-End for Synapse with Docker Compose and Traefik](https://forge.avontech.net/kstro1/matrixrtc-docker-traefik/)
|
||||||
|
|
||||||
## 🛠️ Tools
|
## 🛠️ Tools
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
l.spotlight?.media$ ?? constant(undefined),
|
l.spotlight?.media$ ?? constant(undefined),
|
||||||
...l.grid.map((vm) => vm.media$),
|
...l.grid.map((vm) => vm.media$),
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
|
||||||
(spotlight, ...grid) => ({
|
(spotlight, ...grid) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
spotlight: spotlight?.map((vm) => vm.id),
|
spotlight: spotlight?.map((vm) => vm.id),
|
||||||
@@ -224,7 +223,6 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
|
|||||||
case "spotlight-expanded":
|
case "spotlight-expanded":
|
||||||
return combineLatest(
|
return combineLatest(
|
||||||
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
|
||||||
// eslint-disable-next-line rxjs/finnish -- false positive
|
|
||||||
(spotlight, pip) => ({
|
(spotlight, pip) => ({
|
||||||
type: l.type,
|
type: l.type,
|
||||||
spotlight: spotlight.map((vm) => vm.id),
|
spotlight: spotlight.map((vm) => vm.id),
|
||||||
@@ -785,6 +783,21 @@ test("spotlight speakers swap places", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// While we expect the media on tiles to change, layout$ itself should
|
||||||
|
// *never* meaningfully change. That is, we expect there to be no layout
|
||||||
|
// shifts as the spotlight speaker changes; instead, the same tiles
|
||||||
|
// should be reused for the whole duration of the test and simply have
|
||||||
|
// their media swapped out. This is meaningful for keeping the interface
|
||||||
|
// not too visually distracting during back-and-forth conversations,
|
||||||
|
// while still animating tiles to express people joining, leaving, etc.
|
||||||
|
expectObservable(
|
||||||
|
vm.layout$.pipe(
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
|
debounceTime(0),
|
||||||
|
map(() => "x"),
|
||||||
|
),
|
||||||
|
).toBe("x"); // Expect just one emission
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -827,6 +840,84 @@ test("layout enters picture-in-picture mode when requested", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => {
|
||||||
|
withTestScheduler(({ behavior, schedule, expectObservable }) => {
|
||||||
|
// Switch to spotlight immediately
|
||||||
|
const modeInputMarbles = " s";
|
||||||
|
// And expand the spotlight immediately
|
||||||
|
const expandInputMarbles = " a";
|
||||||
|
// First Bob speaks, then Dave, then Bob again
|
||||||
|
const bSpeakingInputMarbles = "n-yn--yn";
|
||||||
|
const dSpeakingInputMarbles = "n---yn";
|
||||||
|
// Should show Alice (presenter) in the PiP, then Bob, then Dave, then Bob
|
||||||
|
// again
|
||||||
|
const expectedLayoutMarbles = "a-b-c-b";
|
||||||
|
|
||||||
|
withCallViewModel(
|
||||||
|
{
|
||||||
|
remoteParticipants$: constant([
|
||||||
|
aliceSharingScreen,
|
||||||
|
bobParticipant,
|
||||||
|
daveParticipant,
|
||||||
|
]),
|
||||||
|
rtcMembers$: constant([
|
||||||
|
localRtcMember,
|
||||||
|
aliceRtcMember,
|
||||||
|
bobRtcMember,
|
||||||
|
daveRtcMember,
|
||||||
|
]),
|
||||||
|
speaking: new Map([
|
||||||
|
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
|
||||||
|
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
(vm) => {
|
||||||
|
schedule(modeInputMarbles, {
|
||||||
|
s: () => vm.setGridMode("spotlight"),
|
||||||
|
});
|
||||||
|
schedule(expandInputMarbles, {
|
||||||
|
a: () => vm.toggleSpotlightExpanded$.value!(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
|
expectedLayoutMarbles,
|
||||||
|
{
|
||||||
|
a: {
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
spotlight: [`${aliceId}:0:screen-share`],
|
||||||
|
pip: `${aliceId}:0`,
|
||||||
|
},
|
||||||
|
b: {
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
spotlight: [`${aliceId}:0:screen-share`],
|
||||||
|
pip: `${bobId}:0`,
|
||||||
|
},
|
||||||
|
c: {
|
||||||
|
type: "spotlight-expanded",
|
||||||
|
spotlight: [`${aliceId}:0:screen-share`],
|
||||||
|
pip: `${daveId}:0`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// While we expect the media on the PiP tile to change, layout$ itself
|
||||||
|
// should *never* meaningfully change. That is, we expect the same PiP
|
||||||
|
// tile to exist throughout the test and just have its media swapped out
|
||||||
|
// when the speaker changes, rather than for tiles to animate in/out.
|
||||||
|
// This is meaningful for keeping the interface not too visually
|
||||||
|
// distracting during back-and-forth conversations.
|
||||||
|
expectObservable(
|
||||||
|
vm.layout$.pipe(
|
||||||
|
distinctUntilChanged(deepCompare),
|
||||||
|
debounceTime(0),
|
||||||
|
map(() => "x"),
|
||||||
|
),
|
||||||
|
).toBe("x"); // Expect just one emission
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("spotlight remembers whether it's expanded", () => {
|
test("spotlight remembers whether it's expanded", () => {
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
// Start in spotlight mode, then switch to grid and back to spotlight a
|
// Start in spotlight mode, then switch to grid and back to spotlight a
|
||||||
@@ -849,11 +940,7 @@ test("spotlight remembers whether it's expanded", () => {
|
|||||||
g: () => vm.setGridMode("grid"),
|
g: () => vm.setGridMode("grid"),
|
||||||
});
|
});
|
||||||
schedule(expandInputMarbles, {
|
schedule(expandInputMarbles, {
|
||||||
a: () => {
|
a: () => vm.toggleSpotlightExpanded$.value!(),
|
||||||
let toggle: () => void;
|
|
||||||
vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!));
|
|
||||||
toggle!();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function spotlightExpandedLayout(
|
|||||||
): [SpotlightExpandedLayout, TileStore] {
|
): [SpotlightExpandedLayout, TileStore] {
|
||||||
const update = prevTiles.from(1);
|
const update = prevTiles.from(1);
|
||||||
update.registerSpotlight(media.spotlight, true);
|
update.registerSpotlight(media.spotlight, true);
|
||||||
if (media.pip !== undefined) update.registerGridTile(media.pip);
|
if (media.pip !== undefined) update.registerPipTile(media.pip);
|
||||||
const tiles = update.build();
|
const tiles = update.build();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -110,10 +110,11 @@ export class TileStore {
|
|||||||
*/
|
*/
|
||||||
export class TileStoreBuilder {
|
export class TileStoreBuilder {
|
||||||
private spotlight: SpotlightTileData | null = null;
|
private spotlight: SpotlightTileData | null = null;
|
||||||
private readonly prevSpotlightSpeaker =
|
private readonly prevSpotlightSpeaker: UserMediaViewModel | null =
|
||||||
this.prevSpotlight?.media.length === 1 &&
|
this.prevSpotlight?.media.length === 1 &&
|
||||||
"speaking" in this.prevSpotlight.media[0] &&
|
"speaking$" in this.prevSpotlight.media[0]
|
||||||
this.prevSpotlight.media[0];
|
? this.prevSpotlight.media[0]
|
||||||
|
: null;
|
||||||
|
|
||||||
private readonly prevGridByMedia: Map<
|
private readonly prevGridByMedia: Map<
|
||||||
MediaViewModel,
|
MediaViewModel,
|
||||||
@@ -193,8 +194,9 @@ export class TileStoreBuilder {
|
|||||||
if (
|
if (
|
||||||
media === this.prevSpotlightSpeaker &&
|
media === this.prevSpotlightSpeaker &&
|
||||||
this.spotlight.media.length === 1 &&
|
this.spotlight.media.length === 1 &&
|
||||||
"speaking" in this.spotlight.media[0] &&
|
"speaking$" in this.spotlight.media[0] &&
|
||||||
this.prevSpotlightSpeaker !== this.spotlight.media[0]
|
this.prevSpotlightSpeaker !==
|
||||||
|
(this.spotlight.media[0] satisfies UserMediaViewModel)
|
||||||
) {
|
) {
|
||||||
const prev = this.prevGridByMedia.get(this.spotlight.media[0]);
|
const prev = this.prevGridByMedia.get(this.spotlight.media[0]);
|
||||||
if (prev !== undefined) {
|
if (prev !== undefined) {
|
||||||
@@ -252,6 +254,33 @@ export class TileStoreBuilder {
|
|||||||
this.numGridEntries++;
|
this.numGridEntries++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a PiP tile for the given media. This is a special kind of grid tile
|
||||||
|
* that is expected to stand on its own and switch between speakers, so this
|
||||||
|
* method will more eagerly try to reuse an existing tile, replacing its
|
||||||
|
* media, than registerGridTile would.
|
||||||
|
*/
|
||||||
|
public registerPipTile(media: UserMediaViewModel): void {
|
||||||
|
if (DEBUG_ENABLED)
|
||||||
|
logger.debug(
|
||||||
|
`[TileStore, ${this.generation}] register PiP tile: ${media.member?.rawDisplayName ?? "[👻]"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there is a single grid tile that we can reuse
|
||||||
|
if (this.prevGrid.length === 1) {
|
||||||
|
const entry = this.prevGrid[0];
|
||||||
|
this.stationaryGridEntries[0] = entry;
|
||||||
|
// Do the media swap
|
||||||
|
entry.media = media;
|
||||||
|
this.prevGridByMedia.delete(entry.media);
|
||||||
|
this.prevGridByMedia.set(media, [entry, 0]);
|
||||||
|
} else {
|
||||||
|
this.visibleGridEntries.push(new GridTileData(media));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.numGridEntries++;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a new collection of all registered tiles, transferring ownership
|
* Constructs a new collection of all registered tiles, transferring ownership
|
||||||
* of the tiles to the new collection. Any tiles present in the previous
|
* of the tiles to the new collection. Any tiles present in the previous
|
||||||
|
|||||||
Reference in New Issue
Block a user