Use finnish notation for observables (#2905)

To help make our usage of the observables more readable/intuitive.
This commit is contained in:
Hugh Nimmo-Smith
2024-12-17 04:01:56 +00:00
committed by GitHub
parent e4bd9d7cf9
commit 79c40f198c
30 changed files with 491 additions and 490 deletions

View File

@@ -124,15 +124,15 @@ export type LayoutSummary =
| OneOnOneLayoutSummary
| PipLayoutSummary;
function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
return l.pipe(
function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return l$.pipe(
switchMap((l) => {
switch (l.type) {
case "grid":
return combineLatest(
[
l.spotlight?.media ?? of(undefined),
...l.grid.map((vm) => vm.media),
l.spotlight?.media$ ?? of(undefined),
...l.grid.map((vm) => vm.media$),
],
(spotlight, ...grid) => ({
type: l.type,
@@ -143,7 +143,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape":
case "spotlight-portrait":
return combineLatest(
[l.spotlight.media, ...l.grid.map((vm) => vm.media)],
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -152,7 +152,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
);
case "spotlight-expanded":
return combineLatest(
[l.spotlight.media, l.pip?.media ?? of(undefined)],
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -161,7 +161,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
);
case "one-on-one":
return combineLatest(
[l.local.media, l.remote.media],
[l.local.media$, l.remote.media$],
(local, remote) => ({
type: l.type,
local: local.id,
@@ -169,7 +169,7 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
}),
);
case "pip":
return l.spotlight.media.pipe(
return l.spotlight.media$.pipe(
map((spotlight) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -186,9 +186,9 @@ function summarizeLayout(l: Observable<Layout>): Observable<LayoutSummary> {
}
function withCallViewModel(
remoteParticipants: Observable<RemoteParticipant[]>,
rtcMembers: Observable<Partial<CallMembership>[]>,
connectionState: Observable<ECConnectionState>,
remoteParticipants$: Observable<RemoteParticipant[]>,
rtcMembers$: Observable<Partial<CallMembership>[]>,
connectionState$: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>,
continuation: (vm: CallViewModel) => void,
): void {
@@ -203,10 +203,10 @@ function withCallViewModel(
room,
localRtcMember,
[],
).withMemberships(rtcMembers);
).withMemberships(rtcMembers$);
const participantsSpy = vi
.spyOn(ComponentsCore, "connectedParticipantsObserver")
.mockReturnValue(remoteParticipants);
.mockReturnValue(remoteParticipants$);
const mediaSpy = vi
.spyOn(ComponentsCore, "observeParticipantMedia")
.mockImplementation((p) =>
@@ -232,7 +232,7 @@ function withCallViewModel(
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants },
{ remoteParticipants$ },
);
const vm = new CallViewModel(
@@ -241,7 +241,7 @@ function withCallViewModel(
{
kind: E2eeType.PER_PARTICIPANT,
},
connectionState,
connectionState$,
);
onTestFinished(() => {
@@ -276,7 +276,7 @@ test("participants are retained during a focus switch", () => {
}),
new Map(),
(vm) => {
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -320,7 +320,7 @@ test("screen sharing activates spotlight layout", () => {
g: () => vm.setGridMode("grid"),
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -363,7 +363,7 @@ test("screen sharing activates spotlight layout", () => {
},
},
);
expectObservable(vm.showSpeakingIndicators).toBe(
expectObservable(vm.showSpeakingIndicators$).toBe(
expectedShowSpeakingMarbles,
{
y: true,
@@ -402,13 +402,13 @@ test("participants stay in the same order unless to appear/disappear", () => {
a: () => {
// We imagine that only three tiles (the first three) will be visible
// on screen at a time
vm.layout.subscribe((layout) => {
vm.layout$.subscribe((layout) => {
if (layout.type === "grid") layout.setVisibleTiles(3);
});
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -455,7 +455,7 @@ test("participants adjust order when space becomes constrained", () => {
]),
(vm) => {
let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout.subscribe((layout) => {
vm.layout$.subscribe((layout) => {
if (layout.type === "grid") setVisibleTiles = layout.setVisibleTiles;
});
schedule(visibilityInputMarbles, {
@@ -463,7 +463,7 @@ test("participants adjust order when space becomes constrained", () => {
b: () => setVisibleTiles!(3),
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -509,7 +509,7 @@ test("spotlight speakers swap places", () => {
(vm) => {
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -557,7 +557,7 @@ test("layout enters picture-in-picture mode when requested", () => {
d: () => window.controls.disablePip(),
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -600,12 +600,12 @@ test("spotlight remembers whether it's expanded", () => {
schedule(expandInputMarbles, {
a: () => {
let toggle: () => void;
vm.toggleSpotlightExpanded.subscribe((val) => (toggle = val!));
vm.toggleSpotlightExpanded$.subscribe((val) => (toggle = val!));
toggle!();
},
});
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -662,7 +662,7 @@ test("participants must have a MatrixRTCSession to be visible", () => {
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -706,7 +706,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
@@ -753,7 +753,7 @@ it("should show at least one tile per MatrixRTCSession", () => {
new Map(),
(vm) => {
vm.setGridMode("grid");
expectObservable(summarizeLayout(vm.layout)).toBe(
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {

View File

@@ -62,7 +62,7 @@ import {
import {
LocalUserMediaViewModel,
type MediaViewModel,
observeTrackReference,
observeTrackReference$,
RemoteUserMediaViewModel,
ScreenShareViewModel,
type UserMediaViewModel,
@@ -71,7 +71,7 @@ import { accumulate, finalizeValue } from "../utils/observable";
import { ObservableScope } from "./ObservableScope";
import { duplicateTiles, showNonMemberTiles } from "../settings/settings";
import { isFirefox } from "../Platform";
import { setPipEnabled } from "../controls";
import { setPipEnabled$ } from "../controls";
import {
type GridTileViewModel,
type SpotlightTileViewModel,
@@ -82,7 +82,7 @@ import { spotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { oneOnOneLayout } from "./OneOnOneLayout";
import { pipLayout } from "./PipLayout";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { observeSpeaker } from "./observeSpeaker";
import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array";
// How long we wait after a focus switch before showing the real participant
@@ -232,12 +232,12 @@ interface LayoutScanState {
class UserMedia {
private readonly scope = new ObservableScope();
public readonly vm: UserMediaViewModel;
private readonly participant: BehaviorSubject<
private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant | undefined
>;
public readonly speaker: Observable<boolean>;
public readonly presenter: Observable<boolean>;
public readonly speaker$: Observable<boolean>;
public readonly presenter$: Observable<boolean>;
public constructor(
public readonly id: string,
member: RoomMember | undefined,
@@ -245,13 +245,13 @@ class UserMedia {
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
this.participant = new BehaviorSubject(participant);
this.participant$ = new BehaviorSubject(participant);
if (participant?.isLocal) {
this.vm = new LocalUserMediaViewModel(
this.id,
member,
this.participant.asObservable() as Observable<LocalParticipant>,
this.participant$.asObservable() as Observable<LocalParticipant>,
encryptionSystem,
livekitRoom,
);
@@ -259,7 +259,7 @@ class UserMedia {
this.vm = new RemoteUserMediaViewModel(
id,
member,
this.participant.asObservable() as Observable<
this.participant$.asObservable() as Observable<
RemoteParticipant | undefined
>,
encryptionSystem,
@@ -267,9 +267,9 @@ class UserMedia {
);
}
this.speaker = observeSpeaker(this.vm.speaking).pipe(this.scope.state());
this.speaker$ = observeSpeaker$(this.vm.speaking$).pipe(this.scope.state());
this.presenter = this.participant.pipe(
this.presenter$ = this.participant$.pipe(
switchMap(
(p) =>
(p &&
@@ -289,9 +289,9 @@ class UserMedia {
public updateParticipant(
newParticipant: LocalParticipant | RemoteParticipant | undefined,
): void {
if (this.participant.value !== newParticipant) {
if (this.participant$.value !== newParticipant) {
// Update the BehaviourSubject in the UserMedia.
this.participant.next(newParticipant);
this.participant$.next(newParticipant);
}
}
@@ -303,7 +303,7 @@ class UserMedia {
class ScreenShare {
public readonly vm: ScreenShareViewModel;
private readonly participant: BehaviorSubject<
private readonly participant$: BehaviorSubject<
LocalParticipant | RemoteParticipant
>;
@@ -314,12 +314,12 @@ class ScreenShare {
encryptionSystem: EncryptionSystem,
liveKitRoom: LivekitRoom,
) {
this.participant = new BehaviorSubject(participant);
this.participant$ = new BehaviorSubject(participant);
this.vm = new ScreenShareViewModel(
id,
member,
this.participant.asObservable(),
this.participant$.asObservable(),
encryptionSystem,
liveKitRoom,
participant.isLocal,
@@ -357,8 +357,8 @@ function findMatrixRoomMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel {
public readonly localVideo: Observable<LocalVideoTrack | null> =
observeTrackReference(
public readonly localVideo$: Observable<LocalVideoTrack | null> =
observeTrackReference$(
of(this.livekitRoom.localParticipant),
Track.Source.Camera,
).pipe(
@@ -371,16 +371,16 @@ export class CallViewModel extends ViewModel {
/**
* The raw list of RemoteParticipants as reported by LiveKit
*/
private readonly rawRemoteParticipants: Observable<RemoteParticipant[]> =
private readonly rawRemoteParticipants$: Observable<RemoteParticipant[]> =
connectedParticipantsObserver(this.livekitRoom).pipe(this.scope.state());
/**
* Lists of RemoteParticipants to "hold" on display, even if LiveKit claims that
* they've left
*/
private readonly remoteParticipantHolds: Observable<RemoteParticipant[][]> =
this.connectionState.pipe(
withLatestFrom(this.rawRemoteParticipants),
private readonly remoteParticipantHolds$: Observable<RemoteParticipant[][]> =
this.connectionState$.pipe(
withLatestFrom(this.rawRemoteParticipants$),
mergeMap(([s, ps]) => {
// Whenever we switch focuses, we should retain all the previous
// participants for at least POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS ms to
@@ -392,7 +392,7 @@ export class CallViewModel extends ViewModel {
// Wait for time to pass and the connection state to have changed
forkJoin([
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
this.connectionState.pipe(
this.connectionState$.pipe(
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
take(1),
),
@@ -415,9 +415,9 @@ export class CallViewModel extends ViewModel {
/**
* The RemoteParticipants including those that are being "held" on the screen
*/
private readonly remoteParticipants: Observable<RemoteParticipant[]> =
private readonly remoteParticipants$: Observable<RemoteParticipant[]> =
combineLatest(
[this.rawRemoteParticipants, this.remoteParticipantHolds],
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
(raw, holds) => {
const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity));
@@ -439,10 +439,10 @@ export class CallViewModel extends ViewModel {
/**
* List of MediaItems that we want to display
*/
private readonly mediaItems: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants,
private readonly mediaItems$: Observable<MediaItem[]> = combineLatest([
this.remoteParticipants$,
observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value,
duplicateTiles.value$,
// Also react to changes in the MatrixRTC session list.
// The session list will also be update if a room membership changes.
// No additional RoomState event listener needs to be set up.
@@ -450,7 +450,7 @@ export class CallViewModel extends ViewModel {
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
).pipe(startWith(null)),
showNonMemberTiles.value,
showNonMemberTiles.value$,
]).pipe(
scan(
(
@@ -606,13 +606,13 @@ export class CallViewModel extends ViewModel {
/**
* List of MediaItems that we want to display, that are of type UserMedia
*/
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
private readonly userMedia$: Observable<UserMedia[]> = this.mediaItems$.pipe(
map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
),
);
public readonly memberChanges = this.userMedia
public readonly memberChanges$ = this.userMedia$
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
.pipe(
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
@@ -628,22 +628,22 @@ export class CallViewModel extends ViewModel {
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe(
private readonly screenShares$: Observable<ScreenShare[]> =
this.mediaItems$.pipe(
map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
),
this.scope.state(),
);
private readonly spotlightSpeaker: Observable<UserMediaViewModel | null> =
this.userMedia.pipe(
private readonly spotlightSpeaker$: Observable<UserMediaViewModel | null> =
this.userMedia$.pipe(
switchMap((mediaItems) =>
mediaItems.length === 0
? of([])
: combineLatest(
mediaItems.map((m) =>
m.vm.speaking.pipe(map((s) => [m, s] as const)),
m.vm.speaking$.pipe(map((s) => [m, s] as const)),
),
),
),
@@ -672,52 +672,53 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
combineLatest(
[
m.speaker,
m.presenter,
m.vm.videoEnabled,
m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow
: of(false),
],
(speaker, presenter, video, alwaysShow) => {
let bin: SortingBin;
if (m.vm.local)
bin = alwaysShow
? SortingBin.SelfAlwaysShown
: SortingBin.SelfNotAlwaysShown;
else if (presenter) bin = SortingBin.Presenters;
else if (speaker) bin = SortingBin.Speakers;
else if (video) bin = SortingBin.Video;
else bin = SortingBin.NoVideo;
private readonly grid$: Observable<UserMediaViewModel[]> =
this.userMedia$.pipe(
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
combineLatest(
[
m.speaker$,
m.presenter$,
m.vm.videoEnabled$,
m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow$
: of(false),
],
(speaker, presenter, video, alwaysShow) => {
let bin: SortingBin;
if (m.vm.local)
bin = alwaysShow
? SortingBin.SelfAlwaysShown
: SortingBin.SelfNotAlwaysShown;
else if (presenter) bin = SortingBin.Presenters;
else if (speaker) bin = SortingBin.Speakers;
else if (video) bin = SortingBin.Video;
else bin = SortingBin.NoVideo;
return [m, bin] as const;
},
),
);
// Sort the media by bin order and generate a tile for each one
return bins.length === 0
? of([])
: combineLatest(bins, (...bins) =>
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
);
}),
distinctUntilChanged(shallowEquals),
this.scope.state(),
);
return [m, bin] as const;
},
),
);
// Sort the media by bin order and generate a tile for each one
return bins.length === 0
? of([])
: combineLatest(bins, (...bins) =>
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m.vm),
);
}),
distinctUntilChanged(shallowEquals),
this.scope.state(),
);
private readonly spotlight: Observable<MediaViewModel[]> =
this.screenShares.pipe(
private readonly spotlight$: Observable<MediaViewModel[]> =
this.screenShares$.pipe(
switchMap((screenShares) => {
if (screenShares.length > 0) {
return of(screenShares.map((m) => m.vm));
}
return this.spotlightSpeaker.pipe(
return this.spotlightSpeaker$.pipe(
map((speaker) => (speaker ? [speaker] : [])),
);
}),
@@ -725,14 +726,14 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
private readonly pip: Observable<UserMediaViewModel | null> = combineLatest([
this.screenShares,
this.spotlightSpeaker,
this.mediaItems,
private readonly pip$: Observable<UserMediaViewModel | null> = combineLatest([
this.screenShares$,
this.spotlightSpeaker$,
this.mediaItems$,
]).pipe(
switchMap(([screenShares, spotlight, mediaItems]) => {
if (screenShares.length > 0) {
return this.spotlightSpeaker;
return this.spotlightSpeaker$;
}
if (!spotlight || spotlight.local) {
return of(null);
@@ -749,7 +750,7 @@ export class CallViewModel extends ViewModel {
if (!localUserMediaViewModel) {
return of(null);
}
return localUserMediaViewModel.alwaysShow.pipe(
return localUserMediaViewModel.alwaysShow$.pipe(
map((alwaysShow) => {
if (alwaysShow) {
return localUserMediaViewModel;
@@ -762,19 +763,19 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
private readonly hasRemoteScreenShares: Observable<boolean> =
this.spotlight.pipe(
private readonly hasRemoteScreenShares$: Observable<boolean> =
this.spotlight$.pipe(
map((spotlight) =>
spotlight.some((vm) => !vm.local && vm instanceof ScreenShareViewModel),
),
distinctUntilChanged(),
);
private readonly pipEnabled: Observable<boolean> = setPipEnabled.pipe(
private readonly pipEnabled$: Observable<boolean> = setPipEnabled$.pipe(
startWith(false),
);
private readonly naturalWindowMode: Observable<WindowMode> = fromEvent(
private readonly naturalWindowMode$: Observable<WindowMode> = fromEvent(
window,
"resize",
).pipe(
@@ -796,30 +797,30 @@ export class CallViewModel extends ViewModel {
/**
* The general shape of the window.
*/
public readonly windowMode: Observable<WindowMode> = this.pipEnabled.pipe(
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode)),
public readonly windowMode$: Observable<WindowMode> = this.pipEnabled$.pipe(
switchMap((pip) => (pip ? of<WindowMode>("pip") : this.naturalWindowMode$)),
);
private readonly spotlightExpandedToggle = new Subject<void>();
public readonly spotlightExpanded: Observable<boolean> =
this.spotlightExpandedToggle.pipe(
private readonly spotlightExpandedToggle$ = new Subject<void>();
public readonly spotlightExpanded$: Observable<boolean> =
this.spotlightExpandedToggle$.pipe(
accumulate(false, (expanded) => !expanded),
this.scope.state(),
);
private readonly gridModeUserSelection = new Subject<GridMode>();
private readonly gridModeUserSelection$ = new Subject<GridMode>();
/**
* The layout mode of the media tile grid.
*/
public readonly gridMode: Observable<GridMode> =
public readonly gridMode$: Observable<GridMode> =
// If the user hasn't selected spotlight and somebody starts screen sharing,
// automatically switch to spotlight mode and reset when screen sharing ends
this.gridModeUserSelection.pipe(
this.gridModeUserSelection$.pipe(
startWith(null),
switchMap((userSelection) =>
(userSelection === "spotlight"
? EMPTY
: combineLatest([this.hasRemoteScreenShares, this.windowMode]).pipe(
: combineLatest([this.hasRemoteScreenShares$, this.windowMode$]).pipe(
skip(userSelection === null ? 0 : 1),
map(
([hasScreenShares, windowMode]): GridMode =>
@@ -834,43 +835,41 @@ export class CallViewModel extends ViewModel {
);
public setGridMode(value: GridMode): void {
this.gridModeUserSelection.next(value);
this.gridModeUserSelection$.next(value);
}
private readonly gridLayoutMedia: Observable<GridLayoutMedia> = combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) => ({
private readonly gridLayoutMedia$: Observable<GridLayoutMedia> =
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "grid",
spotlight: spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? spotlight
: undefined,
grid,
}),
);
}));
private readonly spotlightLandscapeLayoutMedia: Observable<SpotlightLandscapeLayoutMedia> =
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
private readonly spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape",
spotlight,
grid,
}));
private readonly spotlightPortraitLayoutMedia: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([this.grid, this.spotlight], (grid, spotlight) => ({
private readonly spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([this.grid$, this.spotlight$], (grid, spotlight) => ({
type: "spotlight-portrait",
spotlight,
grid,
}));
private readonly spotlightExpandedLayoutMedia: Observable<SpotlightExpandedLayoutMedia> =
combineLatest([this.spotlight, this.pip], (spotlight, pip) => ({
private readonly spotlightExpandedLayoutMedia$: Observable<SpotlightExpandedLayoutMedia> =
combineLatest([this.spotlight$, this.pip$], (spotlight, pip) => ({
type: "spotlight-expanded",
spotlight,
pip: pip ?? undefined,
}));
private readonly oneOnOneLayoutMedia: Observable<OneOnOneLayoutMedia | null> =
this.mediaItems.pipe(
private readonly oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
this.mediaItems$.pipe(
map((mediaItems) => {
if (mediaItems.length !== 2) return null;
const local = mediaItems.find((vm) => vm.vm.local)?.vm as
@@ -888,86 +887,91 @@ export class CallViewModel extends ViewModel {
}),
);
private readonly pipLayoutMedia: Observable<LayoutMedia> =
this.spotlight.pipe(map((spotlight) => ({ type: "pip", spotlight })));
private readonly pipLayoutMedia$: Observable<LayoutMedia> =
this.spotlight$.pipe(map((spotlight) => ({ type: "pip", spotlight })));
/**
* The media to be used to produce a layout.
*/
private readonly layoutMedia: Observable<LayoutMedia> = this.windowMode.pipe(
switchMap((windowMode) => {
switch (windowMode) {
case "normal":
return this.gridMode.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
return this.oneOnOneLayoutMedia.pipe(
switchMap((oneOnOne) =>
oneOnOne === null ? this.gridLayoutMedia : of(oneOnOne),
),
);
case "spotlight":
return this.spotlightExpanded.pipe(
switchMap((expanded) =>
expanded
? this.spotlightExpandedLayoutMedia
: this.spotlightLandscapeLayoutMedia,
),
);
}
}),
);
case "narrow":
return this.oneOnOneLayoutMedia.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? combineLatest(
[this.grid, this.spotlight],
(grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm instanceof ScreenShareViewModel)
? this.spotlightPortraitLayoutMedia
: this.gridLayoutMedia,
).pipe(switchAll())
: // The expanded spotlight layout makes for a better one-on-one
// experience in narrow windows
this.spotlightExpandedLayoutMedia,
),
);
case "flat":
return this.gridMode.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return this.spotlightLandscapeLayoutMedia;
case "spotlight":
return this.spotlightExpandedLayoutMedia;
}
}),
);
case "pip":
return this.pipLayoutMedia;
}
}),
this.scope.state(),
);
private readonly layoutMedia$: Observable<LayoutMedia> =
this.windowMode$.pipe(
switchMap((windowMode) => {
switch (windowMode) {
case "normal":
return this.gridMode$.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
return this.oneOnOneLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? this.gridLayoutMedia$
: of(oneOnOne),
),
);
case "spotlight":
return this.spotlightExpanded$.pipe(
switchMap((expanded) =>
expanded
? this.spotlightExpandedLayoutMedia$
: this.spotlightLandscapeLayoutMedia$,
),
);
}
}),
);
case "narrow":
return this.oneOnOneLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? combineLatest(
[this.grid$, this.spotlight$],
(grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some(
(vm) => vm instanceof ScreenShareViewModel,
)
? this.spotlightPortraitLayoutMedia$
: this.gridLayoutMedia$,
).pipe(switchAll())
: // The expanded spotlight layout makes for a better one-on-one
// experience in narrow windows
this.spotlightExpandedLayoutMedia$,
),
);
case "flat":
return this.gridMode$.pipe(
switchMap((gridMode) => {
switch (gridMode) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return this.spotlightLandscapeLayoutMedia$;
case "spotlight":
return this.spotlightExpandedLayoutMedia$;
}
}),
);
case "pip":
return this.pipLayoutMedia$;
}
}),
this.scope.state(),
);
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
// first render a layout. To deal with this we assume initially that no tiles
// are visible, and loop the data back into the layouts with a Subject.
private readonly visibleTiles = new Subject<number>();
private readonly visibleTiles$ = new Subject<number>();
private readonly setVisibleTiles = (value: number): void =>
this.visibleTiles.next(value);
this.visibleTiles$.next(value);
public readonly layoutInternals: Observable<
public readonly layoutInternals$: Observable<
LayoutScanState & { layout: Layout }
> = combineLatest([
this.layoutMedia,
this.visibleTiles.pipe(startWith(0), distinctUntilChanged()),
this.layoutMedia$,
this.visibleTiles$.pipe(startWith(0), distinctUntilChanged()),
]).pipe(
scan<
[LayoutMedia, number],
@@ -1009,7 +1013,7 @@ export class CallViewModel extends ViewModel {
/**
* The layout of tiles in the call interface.
*/
public readonly layout: Observable<Layout> = this.layoutInternals.pipe(
public readonly layout$: Observable<Layout> = this.layoutInternals$.pipe(
map(({ layout }) => layout),
this.scope.state(),
);
@@ -1017,18 +1021,18 @@ export class CallViewModel extends ViewModel {
/**
* The current generation of the tile store, exposed for debugging purposes.
*/
public readonly tileStoreGeneration: Observable<number> =
this.layoutInternals.pipe(
public readonly tileStoreGeneration$: Observable<number> =
this.layoutInternals$.pipe(
map(({ tiles }) => tiles.generation),
this.scope.state(),
);
public showSpotlightIndicators: Observable<boolean> = this.layout.pipe(
public showSpotlightIndicators$: Observable<boolean> = this.layout$.pipe(
map((l) => l.type !== "grid"),
this.scope.state(),
);
public showSpeakingIndicators: Observable<boolean> = this.layout.pipe(
public showSpeakingIndicators$: Observable<boolean> = this.layout$.pipe(
switchMap((l) => {
switch (l.type) {
case "spotlight-landscape":
@@ -1036,7 +1040,7 @@ export class CallViewModel extends ViewModel {
// If the spotlight is showing the active speaker, we can do without
// speaking indicators as they're a redundant visual cue. But if
// screen sharing feeds are in the spotlight we still need them.
return l.spotlight.media.pipe(
return l.spotlight.media$.pipe(
map((models: MediaViewModel[]) =>
models.some((m) => m instanceof ScreenShareViewModel),
),
@@ -1055,11 +1059,11 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
public readonly toggleSpotlightExpanded: Observable<(() => void) | null> =
this.windowMode.pipe(
public readonly toggleSpotlightExpanded$: Observable<(() => void) | null> =
this.windowMode$.pipe(
switchMap((mode) =>
mode === "normal"
? this.layout.pipe(
? this.layout$.pipe(
map(
(l) =>
l.type === "spotlight-landscape" ||
@@ -1070,50 +1074,50 @@ export class CallViewModel extends ViewModel {
),
distinctUntilChanged(),
map((enabled) =>
enabled ? (): void => this.spotlightExpandedToggle.next() : null,
enabled ? (): void => this.spotlightExpandedToggle$.next() : null,
),
this.scope.state(),
);
private readonly screenTap = new Subject<void>();
private readonly controlsTap = new Subject<void>();
private readonly screenHover = new Subject<void>();
private readonly screenUnhover = new Subject<void>();
private readonly screenTap$ = new Subject<void>();
private readonly controlsTap$ = new Subject<void>();
private readonly screenHover$ = new Subject<void>();
private readonly screenUnhover$ = new Subject<void>();
/**
* Callback for when the user taps the call view.
*/
public tapScreen(): void {
this.screenTap.next();
this.screenTap$.next();
}
/**
* Callback for when the user taps the call's controls.
*/
public tapControls(): void {
this.controlsTap.next();
this.controlsTap$.next();
}
/**
* Callback for when the user hovers over the call view.
*/
public hoverScreen(): void {
this.screenHover.next();
this.screenHover$.next();
}
/**
* Callback for when the user stops hovering over the call view.
*/
public unhoverScreen(): void {
this.screenUnhover.next();
this.screenUnhover$.next();
}
public readonly showHeader: Observable<boolean> = this.windowMode.pipe(
public readonly showHeader$: Observable<boolean> = this.windowMode$.pipe(
map((mode) => mode !== "pip" && mode !== "flat"),
this.scope.state(),
);
public readonly showFooter: Observable<boolean> = this.windowMode.pipe(
public readonly showFooter$: Observable<boolean> = this.windowMode$.pipe(
switchMap((mode) => {
switch (mode) {
case "pip":
@@ -1128,9 +1132,9 @@ export class CallViewModel extends ViewModel {
if (isFirefox()) return of(true);
// Show/hide the footer in response to interactions
return merge(
this.screenTap.pipe(map(() => "tap screen" as const)),
this.controlsTap.pipe(map(() => "tap controls" as const)),
this.screenHover.pipe(map(() => "hover" as const)),
this.screenTap$.pipe(map(() => "tap screen" as const)),
this.controlsTap$.pipe(map(() => "tap controls" as const)),
this.screenHover$.pipe(map(() => "hover" as const)),
).pipe(
switchScan((state, interaction) => {
switch (interaction) {
@@ -1153,7 +1157,7 @@ export class CallViewModel extends ViewModel {
// Show on hover and hide after a timeout
return race(
timer(showFooterMs),
this.screenUnhover.pipe(take(1)),
this.screenUnhover$.pipe(take(1)),
).pipe(
map(() => false),
startWith(true),
@@ -1172,7 +1176,7 @@ export class CallViewModel extends ViewModel {
private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom,
private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>,
private readonly connectionState$: Observable<ECConnectionState>,
) {
super();
}

View File

@@ -49,7 +49,7 @@ test("control a participant's volume", async () => {
expect(setVolumeSpy).toHaveBeenLastCalledWith(0.8);
},
});
expectObservable(vm.localVolume).toBe("ab(cd)(ef)g", {
expectObservable(vm.localVolume$).toBe("ab(cd)(ef)g", {
a: 1,
b: 0,
c: 0.6,
@@ -69,7 +69,7 @@ test("toggle fit/contain for a participant's video", async () => {
a: () => vm.toggleFitContain(),
b: () => vm.toggleFitContain(),
});
expectObservable(vm.cropVideo).toBe("abc", {
expectObservable(vm.cropVideo$).toBe("abc", {
a: true,
b: false,
c: true,
@@ -82,7 +82,7 @@ test("local media remembers whether it should always be shown", async () => {
await withLocalMedia(rtcMembership, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow).toBe("ab", { a: true, b: false });
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
}),
);
// Next local media should start out *not* always shown
@@ -93,7 +93,7 @@ test("local media remembers whether it should always be shown", async () => {
(vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
expectObservable(vm.alwaysShow).toBe("ab", { a: false, b: true });
expectObservable(vm.alwaysShow$).toBe("ab", { a: false, b: true });
}),
);
});

View File

@@ -74,11 +74,11 @@ export function useDisplayName(vm: MediaViewModel): string {
return displayName;
}
export function observeTrackReference(
participant: Observable<Participant | undefined>,
export function observeTrackReference$(
participant$: Observable<Participant | undefined>,
source: Track.Source,
): Observable<TrackReferenceOrPlaceholder | undefined> {
return participant.pipe(
return participant$.pipe(
switchMap((p) => {
if (p) {
return observeParticipantMedia(p).pipe(
@@ -96,7 +96,7 @@ export function observeTrackReference(
);
}
function observeRemoteTrackReceivingOkay(
function observeRemoteTrackReceivingOkay$(
participant: Participant,
source: Track.Source,
): Observable<boolean | undefined> {
@@ -111,7 +111,7 @@ function observeRemoteTrackReceivingOkay(
};
return combineLatest([
observeTrackReference(of(participant), source),
observeTrackReference$(of(participant), source),
interval(1000).pipe(startWith(0)),
]).pipe(
switchMap(async ([trackReference]) => {
@@ -168,7 +168,7 @@ function observeRemoteTrackReceivingOkay(
);
}
function encryptionErrorObservable(
function encryptionErrorObservable$(
room: LivekitRoom,
participant: Participant,
encryptionSystem: EncryptionSystem,
@@ -209,13 +209,13 @@ abstract class BaseMediaViewModel extends ViewModel {
/**
* The LiveKit video track for this media.
*/
public readonly video: Observable<TrackReferenceOrPlaceholder | undefined>;
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>;
/**
* Whether there should be a warning that this media is unencrypted.
*/
public readonly unencryptedWarning: Observable<boolean>;
public readonly unencryptedWarning$: Observable<boolean>;
public readonly encryptionStatus: Observable<EncryptionStatus>;
public readonly encryptionStatus$: Observable<EncryptionStatus>;
/**
* Whether this media corresponds to the local participant.
@@ -235,7 +235,7 @@ abstract class BaseMediaViewModel extends ViewModel {
public readonly member: RoomMember | undefined,
// We don't necessarily have a participant if a user connects via MatrixRTC but not (yet) through
// livekit.
protected readonly participant: Observable<
protected readonly participant$: Observable<
LocalParticipant | RemoteParticipant | undefined
>,
@@ -245,21 +245,21 @@ abstract class BaseMediaViewModel extends ViewModel {
livekitRoom: LivekitRoom,
) {
super();
const audio = observeTrackReference(participant, audioSource).pipe(
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
this.scope.state(),
);
this.video = observeTrackReference(participant, videoSource).pipe(
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
this.scope.state(),
);
this.unencryptedWarning = combineLatest(
[audio, this.video],
this.unencryptedWarning$ = combineLatest(
[audio$, this.video$],
(a, v) =>
encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false),
).pipe(this.scope.state());
this.encryptionStatus = this.participant.pipe(
this.encryptionStatus$ = this.participant$.pipe(
switchMap((participant): Observable<EncryptionStatus> => {
if (!participant) {
return of(EncryptionStatus.Connecting);
@@ -270,20 +270,20 @@ abstract class BaseMediaViewModel extends ViewModel {
return of(EncryptionStatus.Okay);
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
return combineLatest([
encryptionErrorObservable(
encryptionErrorObservable$(
livekitRoom,
participant,
encryptionSystem,
"MissingKey",
),
encryptionErrorObservable(
encryptionErrorObservable$(
livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource),
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
if (keyMissing) return EncryptionStatus.KeyMissing;
@@ -296,14 +296,14 @@ abstract class BaseMediaViewModel extends ViewModel {
);
} else {
return combineLatest([
encryptionErrorObservable(
encryptionErrorObservable$(
livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay(participant, audioSource),
observeRemoteTrackReceivingOkay(participant, videoSource),
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(
([keyInvalid, audioOkay, videoOkay]):
@@ -339,7 +339,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/**
* Whether the participant is speaking.
*/
public readonly speaking = this.participant.pipe(
public readonly speaking$ = this.participant$.pipe(
switchMap((p) =>
p
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe(
@@ -353,49 +353,49 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
*/
public readonly audioEnabled: Observable<boolean>;
public readonly audioEnabled$: Observable<boolean>;
/**
* Whether this participant is sending video.
*/
public readonly videoEnabled: Observable<boolean>;
public readonly videoEnabled$: Observable<boolean>;
private readonly _cropVideo = new BehaviorSubject(true);
private readonly _cropVideo$ = new BehaviorSubject(true);
/**
* Whether the tile video should be contained inside the tile or be cropped to fit.
*/
public readonly cropVideo: Observable<boolean> = this._cropVideo;
public readonly cropVideo$: Observable<boolean> = this._cropVideo$;
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<LocalParticipant | RemoteParticipant | undefined>,
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
super(
id,
member,
participant,
participant$,
encryptionSystem,
Track.Source.Microphone,
Track.Source.Camera,
livekitRoom,
);
const media = participant.pipe(
const media$ = participant$.pipe(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
this.scope.state(),
);
this.audioEnabled = media.pipe(
this.audioEnabled$ = media$.pipe(
map((m) => m?.microphoneTrack?.isMuted === false),
);
this.videoEnabled = media.pipe(
this.videoEnabled$ = media$.pipe(
map((m) => m?.cameraTrack?.isMuted === false),
);
}
public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value);
this._cropVideo$.next(!this._cropVideo$.value);
}
public get local(): boolean {
@@ -410,7 +410,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether the video should be mirrored.
*/
public readonly mirror = this.video.pipe(
public readonly mirror$ = this.video$.pipe(
switchMap((v) => {
const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
@@ -428,17 +428,17 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* Whether to show this tile in a highly visible location near the start of
* the grid.
*/
public readonly alwaysShow = alwaysShowSelf.value;
public readonly alwaysShow$ = alwaysShowSelf.value$;
public readonly setAlwaysShow = alwaysShowSelf.setValue;
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<LocalParticipant | undefined>,
participant$: Observable<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
super(id, member, participant, encryptionSystem, livekitRoom);
super(id, member, participant$, encryptionSystem, livekitRoom);
}
}
@@ -446,18 +446,18 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* A remote participant's user media.
*/
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
private readonly locallyMutedToggle = new Subject<void>();
private readonly localVolumeAdjustment = new Subject<number>();
private readonly localVolumeCommit = new Subject<void>();
private readonly locallyMutedToggle$ = new Subject<void>();
private readonly localVolumeAdjustment$ = new Subject<number>();
private readonly localVolumeCommit$ = new Subject<void>();
/**
* The volume to which this participant's audio is set, as a scalar
* multiplier.
*/
public readonly localVolume: Observable<number> = merge(
this.locallyMutedToggle.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment,
this.localVolumeCommit.pipe(map(() => "commit" as const)),
public readonly localVolume$: Observable<number> = merge(
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeAdjustment$,
this.localVolumeCommit$.pipe(map(() => "commit" as const)),
).pipe(
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
switch (event) {
@@ -487,7 +487,7 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether this participant's audio is disabled.
*/
public readonly locallyMuted: Observable<boolean> = this.localVolume.pipe(
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe(
map((volume) => volume === 0),
this.scope.state(),
);
@@ -495,29 +495,29 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<RemoteParticipant | undefined>,
participant$: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
) {
super(id, member, participant, encryptionSystem, livekitRoom);
super(id, member, participant$, encryptionSystem, livekitRoom);
// Sync the local volume with LiveKit
combineLatest([
participant,
this.localVolume.pipe(this.scope.bind()),
participant$,
this.localVolume$.pipe(this.scope.bind()),
]).subscribe(([p, volume]) => p && p.setVolume(volume));
}
public toggleLocallyMuted(): void {
this.locallyMutedToggle.next();
this.locallyMutedToggle$.next();
}
public setLocalVolume(value: number): void {
this.localVolumeAdjustment.next(value);
this.localVolumeAdjustment$.next(value);
}
public commitLocalVolume(): void {
this.localVolumeCommit.next();
this.localVolumeCommit$.next();
}
}
@@ -528,7 +528,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
public constructor(
id: string,
member: RoomMember | undefined,
participant: Observable<LocalParticipant | RemoteParticipant>,
participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
public readonly local: boolean,
@@ -536,7 +536,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
super(
id,
member,
participant,
participant$,
encryptionSystem,
Track.Source.ScreenShareAudio,
Track.Source.ScreenShare,

View File

@@ -19,9 +19,9 @@ type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
* A scope which limits the execution lifetime of its bound Observables.
*/
export class ObservableScope {
private readonly ended = new Subject<void>();
private readonly ended$ = new Subject<void>();
private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended);
private readonly bindImpl: MonoTypeOperator = takeUntil(this.ended$);
/**
* Binds an Observable to this scope, so that it completes when the scope
@@ -31,8 +31,8 @@ export class ObservableScope {
return this.bindImpl;
}
private readonly stateImpl: MonoTypeOperator = (o) =>
o.pipe(
private readonly stateImpl: MonoTypeOperator = (o$) =>
o$.pipe(
this.bind(),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
@@ -51,7 +51,7 @@ export class ObservableScope {
* Ends the scope, causing any bound Observables to complete.
*/
public end(): void {
this.ended.next();
this.ended.complete();
this.ended$.next();
this.ended$.complete();
}
}

View File

@@ -18,31 +18,31 @@ function debugEntries(entries: GridTileData[]): string[] {
}
let DEBUG_ENABLED = false;
debugTileLayout.value.subscribe((value) => (DEBUG_ENABLED = value));
debugTileLayout.value$.subscribe((value) => (DEBUG_ENABLED = value));
class SpotlightTileData {
private readonly media_: BehaviorSubject<MediaViewModel[]>;
private readonly media$: BehaviorSubject<MediaViewModel[]>;
public get media(): MediaViewModel[] {
return this.media_.value;
return this.media$.value;
}
public set media(value: MediaViewModel[]) {
this.media_.next(value);
this.media$.next(value);
}
private readonly maximised_: BehaviorSubject<boolean>;
private readonly maximised$: BehaviorSubject<boolean>;
public get maximised(): boolean {
return this.maximised_.value;
return this.maximised$.value;
}
public set maximised(value: boolean) {
this.maximised_.next(value);
this.maximised$.next(value);
}
public readonly vm: SpotlightTileViewModel;
public constructor(media: MediaViewModel[], maximised: boolean) {
this.media_ = new BehaviorSubject(media);
this.maximised_ = new BehaviorSubject(maximised);
this.vm = new SpotlightTileViewModel(this.media_, this.maximised_);
this.media$ = new BehaviorSubject(media);
this.maximised$ = new BehaviorSubject(maximised);
this.vm = new SpotlightTileViewModel(this.media$, this.maximised$);
}
public destroy(): void {
@@ -51,19 +51,19 @@ class SpotlightTileData {
}
class GridTileData {
private readonly media_: BehaviorSubject<UserMediaViewModel>;
private readonly media$: BehaviorSubject<UserMediaViewModel>;
public get media(): UserMediaViewModel {
return this.media_.value;
return this.media$.value;
}
public set media(value: UserMediaViewModel) {
this.media_.next(value);
this.media$.next(value);
}
public readonly vm: GridTileViewModel;
public constructor(media: UserMediaViewModel) {
this.media_ = new BehaviorSubject(media);
this.vm = new GridTileViewModel(this.media_);
this.media$ = new BehaviorSubject(media);
this.vm = new GridTileViewModel(this.media$);
}
public destroy(): void {
@@ -123,7 +123,10 @@ export class TileStoreBuilder {
"speaking" in this.prevSpotlight.media[0] &&
this.prevSpotlight.media[0];
private readonly prevGridByMedia = new Map(
private readonly prevGridByMedia: Map<
MediaViewModel,
[GridTileData, number]
> = new Map(
this.prevGrid.map((entry, i) => [entry.media, [entry, i]] as const),
);

View File

@@ -18,15 +18,15 @@ function createId(): string {
export class GridTileViewModel extends ViewModel {
public readonly id = createId();
public constructor(public readonly media: Observable<UserMediaViewModel>) {
public constructor(public readonly media$: Observable<UserMediaViewModel>) {
super();
}
}
export class SpotlightTileViewModel extends ViewModel {
public constructor(
public readonly media: Observable<MediaViewModel[]>,
public readonly maximised: Observable<boolean>,
public readonly media$: Observable<MediaViewModel[]>,
public readonly maximised$: Observable<boolean>,
) {
super();
}

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { describe, test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { observeSpeaker } from "./observeSpeaker";
import { observeSpeaker$ } from "./observeSpeaker";
const yesNo = {
y: true,
@@ -22,40 +22,36 @@ describe("observeSpeaker", () => {
// should default to false when no input is given
const speakingInputMarbles = "";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("after no speaking", () => {
const speakingInputMarbles = "n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("with speaking for 1ms", () => {
const speakingInputMarbles = "y n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("with speaking for 999ms", () => {
const speakingInputMarbles = "y 999ms n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
@@ -63,20 +59,18 @@ describe("observeSpeaker", () => {
const speakingInputMarbles =
"y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n 199ms y 199ms n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
test("with consecutive speaking then stops speaking", () => {
const speakingInputMarbles = "y y y y y y y y y y n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
});
@@ -87,10 +81,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y";
const expectedOutputMarbles = "n 999ms y";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
@@ -98,10 +91,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y 1s n ";
const expectedOutputMarbles = "n 999ms y 60s n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
@@ -109,10 +101,9 @@ describe("observeSpeaker", () => {
const speakingInputMarbles = " y 5s n ";
const expectedOutputMarbles = "n 999ms y 64s n";
withTestScheduler(({ hot, expectObservable }) => {
expectObservable(observeSpeaker(hot(speakingInputMarbles, yesNo))).toBe(
expectedOutputMarbles,
yesNo,
);
expectObservable(
observeSpeaker$(hot(speakingInputMarbles, yesNo)),
).toBe(expectedOutputMarbles, yesNo);
});
});
});

View File

@@ -18,16 +18,16 @@ import {
* Require 1 second of continuous speaking to become a speaker, and 60 second of
* continuous silence to stop being considered a speaker
*/
export function observeSpeaker(
isSpeakingObservable: Observable<boolean>,
export function observeSpeaker$(
isSpeakingObservable$: Observable<boolean>,
): Observable<boolean> {
const distinct = isSpeakingObservable.pipe(distinctUntilChanged());
const distinct$ = isSpeakingObservable$.pipe(distinctUntilChanged());
return distinct.pipe(
return distinct$.pipe(
// Either change to the new value after the timer or re-emit the same value if it toggles back
// (audit will return the latest (toggled back) value) before the timeout.
audit((s) =>
merge(timer(s ? 1000 : 60000), distinct.pipe(filter((s1) => s1 !== s))),
merge(timer(s ? 1000 : 60000), distinct$.pipe(filter((s1) => s1 !== s))),
),
// Filter the re-emissions (marked as: | ) that happen if we toggle quickly (<1s) from false->true->false|->..
startWith(false),