Skip to content

Commit

Permalink
Merge pull request #2380 from robintown/pin-always-show
Browse files Browse the repository at this point in the history
Add toggle to always show yourself
  • Loading branch information
robintown committed Jul 17, 2024
2 parents e05c6f1 + 2bc56db commit d4a2617
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 225 deletions.
1 change: 1 addition & 0 deletions public/locales/en-GB/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@
"unmute_microphone_button_label": "Unmute microphone",
"version": "Version: {{version}}",
"video_tile": {
"always_show": "Always show",
"change_fit_contain": "Fit to frame",
"exit_full_screen": "Exit full screen",
"full_screen": "Full screen",
Expand Down
4 changes: 2 additions & 2 deletions src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
targetHeight={targetHeight}
className={className}
style={style}
showSpeakingIndicator={showSpeakingIndicators}
showSpeakingIndicators={showSpeakingIndicators}
/>
);
},
Expand Down Expand Up @@ -424,7 +424,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
targetHeight={gridBounds.height}
targetWidth={gridBounds.width}
key={maximisedParticipant.id}
showSpeakingIndicator={false}
showSpeakingIndicators={false}
onOpenProfile={openProfile}
/>
);
Expand Down
2 changes: 2 additions & 0 deletions src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,5 @@ export const videoInput = new Setting<string | undefined>(
"video-input",
undefined,
);

export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
98 changes: 78 additions & 20 deletions src/state/CallViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ import {
} from "../livekit/useECConnectionState";
import { usePrevious } from "../usePrevious";
import {
LocalUserMediaViewModel,
MediaViewModel,
UserMediaViewModel,
RemoteUserMediaViewModel,
ScreenShareViewModel,
UserMediaViewModel,
} from "./MediaViewModel";
import { finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope";
Expand Down Expand Up @@ -130,14 +132,38 @@ export type WindowMode = "normal" | "full screen" | "pip";
* Sorting bins defining the order in which media tiles appear in the layout.
*/
enum SortingBin {
SelfStart,
/**
* Yourself, when the "always show self" option is on.
*/
SelfAlwaysShown,
/**
* Participants that are sharing their screen.
*/
Presenters,
/**
* Participants that have been speaking recently.
*/
Speakers,
/**
* Participants with both video and audio.
*/
VideoAndAudio,
/**
* Participants with video but no audio.
*/
Video,
/**
* Participants with audio but no video.
*/
Audio,
/**
* Participants not sharing any media.
*/
NoMedia,
SelfEnd,
/**
* Yourself, when the "always show self" option is off.
*/
SelfNotAlwaysShown,
}

class UserMedia {
Expand All @@ -152,7 +178,10 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean,
) {
this.vm = new UserMediaViewModel(id, member, participant, callEncrypted);
this.vm =
participant instanceof LocalParticipant
? new LocalUserMediaViewModel(id, member, participant, callEncrypted)
: new RemoteUserMediaViewModel(id, member, participant, callEncrypted);

this.speaker = this.vm.speaking.pipeState(
// Require 1 s of continuous speaking to become a speaker, and 60 s of
Expand Down Expand Up @@ -372,43 +401,49 @@ export class CallViewModel extends ViewModel {
},
new Map<string, MediaItem>(),
),
map((ms) => [...ms.values()]),
map((mediaItems) => [...mediaItems.values()]),
finalizeValue((ts) => {
for (const t of ts) t.destroy();
}),
),
);

private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe(
map((ms) => ms.filter((m): m is UserMedia => m instanceof UserMedia)),
map((mediaItems) =>
mediaItems.filter((m): m is UserMedia => m instanceof UserMedia),
),
);

private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe(
map((ms) => ms.filter((m): m is ScreenShare => m instanceof ScreenShare)),
map((mediaItems) =>
mediaItems.filter((m): m is ScreenShare => m instanceof ScreenShare),
),
);

private readonly spotlightSpeaker: Observable<UserMedia | null> =
this.userMedia.pipe(
switchMap((ms) =>
ms.length === 0
switchMap((mediaItems) =>
mediaItems.length === 0
? of([])
: combineLatest(
ms.map((m) => m.vm.speaking.pipe(map((s) => [m, s] as const))),
mediaItems.map((m) =>
m.vm.speaking.pipe(map((s) => [m, s] as const)),
),
),
),
scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
(prev, ms) =>
(prev, mediaItems) =>
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
ms.find(([m, s]) => m === prev && s)?.[0] ??
mediaItems.find(([m, s]) => m === prev && s)?.[0] ??
// Otherwise, select anyone who is speaking
ms.find(([, s]) => s)?.[0] ??
mediaItems.find(([, s]) => s)?.[0] ??
// Otherwise, stick with the person who was last speaking
prev ??
// Otherwise, spotlight the local user
ms.find(([m]) => m.vm.local)?.[0] ??
mediaItems.find(([m]) => m.vm.local)?.[0] ??
null,
null,
),
Expand All @@ -417,13 +452,24 @@ export class CallViewModel extends ViewModel {
);

private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((ms) => {
const bins = ms.map((m) =>
switchMap((mediaItems) => {
const bins = mediaItems.map((m) =>
combineLatest(
[m.speaker, m.presenter, m.vm.audioEnabled, m.vm.videoEnabled],
(speaker, presenter, audio, video) => {
[
m.speaker,
m.presenter,
m.vm.audioEnabled,
m.vm.videoEnabled,
m.vm instanceof LocalUserMediaViewModel
? m.vm.alwaysShow
: of(false),
],
(speaker, presenter, audio, video, alwaysShow) => {
let bin: SortingBin;
if (m.vm.local) bin = SortingBin.SelfStart;
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)
Expand Down Expand Up @@ -535,7 +581,19 @@ export class CallViewModel extends ViewModel {

const userMediaVm =
tilesById.get(userMediaId)?.data ??
new UserMediaViewModel(userMediaId, member, p, this.encrypted);
(p instanceof LocalParticipant
? new LocalUserMediaViewModel(
userMediaId,
member,
p,
this.encrypted,
)
: new RemoteUserMediaViewModel(
userMediaId,
member,
p,
this.encrypted,
));
tilesById.delete(userMediaId);

const userMediaTile: TileDescriptor<MediaViewModel> = {
Expand Down
126 changes: 82 additions & 44 deletions src/state/MediaViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { useEffect } from "react";

import { ViewModel } from "./ViewModel";
import { useReactiveState } from "../useReactiveState";
import { alwaysShowSelf } from "../settings/settings";

export interface NameData {
/**
Expand Down Expand Up @@ -153,29 +154,14 @@ abstract class BaseMediaViewModel extends ViewModel {
* Some participant's media.
*/
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
export type UserMediaViewModel =
| LocalUserMediaViewModel
| RemoteUserMediaViewModel;

/**
* Some participant's user media.
*/
export class UserMediaViewModel extends BaseMediaViewModel {
/**
* Whether the video should be mirrored.
*/
public readonly mirror = state(
this.video.pipe(
switchMap((v) => {
const track = v.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
// Mirror only front-facing cameras (those that face the user)
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
);
}),
),
);

abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/**
* Whether the participant is speaking.
*/
Expand All @@ -186,19 +172,6 @@ export class UserMediaViewModel extends BaseMediaViewModel {
).pipe(map((p) => p.isSpeaking)),
);

private readonly _locallyMuted = new BehaviorSubject(false);
/**
* Whether we've disabled this participant's audio.
*/
public readonly locallyMuted = state(this._locallyMuted);

private readonly _localVolume = new BehaviorSubject(1);
/**
* The volume to which we've set this participant's audio, as a scalar
* multiplier.
*/
public readonly localVolume = state(this._localVolume);

/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
*/
Expand Down Expand Up @@ -236,26 +209,91 @@ export class UserMediaViewModel extends BaseMediaViewModel {
this.videoEnabled = state(
media.pipe(map((m) => m.cameraTrack?.isMuted === false)),
);
}

public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value);
}
}

/**
* The local participant's user media.
*/
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether the video should be mirrored.
*/
public readonly mirror = state(
this.video.pipe(
switchMap((v) => {
const track = v.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
// Mirror only front-facing cameras (those that face the user)
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
);
}),
),
);

/**
* Whether to show this tile in a highly visible location near the start of
* the grid.
*/
public readonly alwaysShow = alwaysShowSelf.value;
public readonly setAlwaysShow = alwaysShowSelf.setValue;

public constructor(
id: string,
member: RoomMember | undefined,
participant: LocalParticipant,
callEncrypted: boolean,
) {
super(id, member, participant, callEncrypted);
}
}

/**
* A remote participant's user media.
*/
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
private readonly _locallyMuted = new BehaviorSubject(false);
/**
* Whether we've disabled this participant's audio.
*/
public readonly locallyMuted = state(this._locallyMuted);

private readonly _localVolume = new BehaviorSubject(1);
/**
* The volume to which we've set this participant's audio, as a scalar
* multiplier.
*/
public readonly localVolume = state(this._localVolume);

public constructor(
id: string,
member: RoomMember | undefined,
participant: RemoteParticipant,
callEncrypted: boolean,
) {
super(id, member, participant, callEncrypted);

// Sync the local mute state and volume with LiveKit
if (!this.local)
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
muted ? 0 : volume,
)
.pipe(this.scope.bind())
.subscribe((volume) => {
(this.participant as RemoteParticipant).setVolume(volume);
});
combineLatest([this._locallyMuted, this._localVolume], (muted, volume) =>
muted ? 0 : volume,
)
.pipe(this.scope.bind())
.subscribe((volume) => {
(this.participant as RemoteParticipant).setVolume(volume);
});
}

public toggleLocallyMuted(): void {
this._locallyMuted.next(!this._locallyMuted.value);
}

public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value);
}

public setLocalVolume(value: number): void {
this._localVolume.next(value);
}
Expand Down
Loading

0 comments on commit d4a2617

Please sign in to comment.