diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 051bb31c..7127abee 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -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/) - [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/) +- [MatrixRTC Back-End for Synapse with Docker Compose and Traefik](https://forge.avontech.net/kstro1/matrixrtc-docker-traefik/) ## 🛠️ Tools diff --git a/src/state/CallViewModel.test.ts b/src/state/CallViewModel.test.ts index c8f8c5c7..6adaa4ba 100644 --- a/src/state/CallViewModel.test.ts +++ b/src/state/CallViewModel.test.ts @@ -204,7 +204,6 @@ function summarizeLayout$(l$: Observable): Observable { l.spotlight?.media$ ?? constant(undefined), ...l.grid.map((vm) => vm.media$), ], - // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, ...grid) => ({ type: l.type, spotlight: spotlight?.map((vm) => vm.id), @@ -224,7 +223,6 @@ function summarizeLayout$(l$: Observable): Observable { case "spotlight-expanded": return combineLatest( [l.spotlight.media$, l.pip?.media$ ?? constant(undefined)], - // eslint-disable-next-line rxjs/finnish -- false positive (spotlight, pip) => ({ type: l.type, 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", () => { withTestScheduler(({ schedule, expectObservable }) => { // 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"), }); schedule(expandInputMarbles, { - a: () => { - let toggle: () => void; - vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!)); - toggle!(); - }, + a: () => vm.toggleSpotlightExpanded$.value!(), }); expectObservable(summarizeLayout$(vm.layout$)).toBe( diff --git a/src/state/SpotlightExpandedLayout.ts b/src/state/SpotlightExpandedLayout.ts index 8ccc49dd..9dc2c815 100644 --- a/src/state/SpotlightExpandedLayout.ts +++ b/src/state/SpotlightExpandedLayout.ts @@ -20,7 +20,7 @@ export function spotlightExpandedLayout( ): [SpotlightExpandedLayout, TileStore] { const update = prevTiles.from(1); 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(); return [ diff --git a/src/state/TileStore.ts b/src/state/TileStore.ts index 04633fb9..f6db0930 100644 --- a/src/state/TileStore.ts +++ b/src/state/TileStore.ts @@ -110,10 +110,11 @@ export class TileStore { */ export class TileStoreBuilder { private spotlight: SpotlightTileData | null = null; - private readonly prevSpotlightSpeaker = + private readonly prevSpotlightSpeaker: UserMediaViewModel | null = this.prevSpotlight?.media.length === 1 && - "speaking" in this.prevSpotlight.media[0] && - this.prevSpotlight.media[0]; + "speaking$" in this.prevSpotlight.media[0] + ? this.prevSpotlight.media[0] + : null; private readonly prevGridByMedia: Map< MediaViewModel, @@ -193,8 +194,9 @@ export class TileStoreBuilder { if ( media === this.prevSpotlightSpeaker && this.spotlight.media.length === 1 && - "speaking" in this.spotlight.media[0] && - this.prevSpotlightSpeaker !== this.spotlight.media[0] + "speaking$" in this.spotlight.media[0] && + this.prevSpotlightSpeaker !== + (this.spotlight.media[0] satisfies UserMediaViewModel) ) { const prev = this.prevGridByMedia.get(this.spotlight.media[0]); if (prev !== undefined) { @@ -252,6 +254,33 @@ export class TileStoreBuilder { 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 * of the tiles to the new collection. Any tiles present in the previous