Merge pull request #2380 from robintown/pin-always-show

Add toggle to always show yourself
This commit is contained in:
Robin
2024-07-17 15:45:29 -04:00
committed by GitHub
7 changed files with 382 additions and 227 deletions

View File

@@ -156,6 +156,7 @@
"unmute_microphone_button_label": "Unmute microphone", "unmute_microphone_button_label": "Unmute microphone",
"version": "Version: {{version}}", "version": "Version: {{version}}",
"video_tile": { "video_tile": {
"always_show": "Always show",
"change_fit_contain": "Fit to frame", "change_fit_contain": "Fit to frame",
"exit_full_screen": "Exit full screen", "exit_full_screen": "Exit full screen",
"full_screen": "Full screen", "full_screen": "Full screen",

View File

@@ -384,7 +384,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
targetHeight={targetHeight} targetHeight={targetHeight}
className={className} className={className}
style={style} style={style}
showSpeakingIndicator={showSpeakingIndicators} showSpeakingIndicators={showSpeakingIndicators}
/> />
); );
}, },
@@ -424,7 +424,7 @@ export const InCallView: FC<InCallViewProps> = subscribe(
targetHeight={gridBounds.height} targetHeight={gridBounds.height}
targetWidth={gridBounds.width} targetWidth={gridBounds.width}
key={maximisedParticipant.id} key={maximisedParticipant.id}
showSpeakingIndicator={false} showSpeakingIndicators={false}
onOpenProfile={openProfile} onOpenProfile={openProfile}
/> />
); );

View File

@@ -90,3 +90,5 @@ export const videoInput = new Setting<string | undefined>(
"video-input", "video-input",
undefined, undefined,
); );
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);

View File

@@ -61,9 +61,11 @@ import {
} from "../livekit/useECConnectionState"; } from "../livekit/useECConnectionState";
import { usePrevious } from "../usePrevious"; import { usePrevious } from "../usePrevious";
import { import {
LocalUserMediaViewModel,
MediaViewModel, MediaViewModel,
UserMediaViewModel, RemoteUserMediaViewModel,
ScreenShareViewModel, ScreenShareViewModel,
UserMediaViewModel,
} from "./MediaViewModel"; } from "./MediaViewModel";
import { finalizeValue } from "../observable-utils"; import { finalizeValue } from "../observable-utils";
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
@@ -130,14 +132,38 @@ export type WindowMode = "normal" | "full screen" | "pip";
* Sorting bins defining the order in which media tiles appear in the layout. * Sorting bins defining the order in which media tiles appear in the layout.
*/ */
enum SortingBin { enum SortingBin {
SelfStart, /**
* Yourself, when the "always show self" option is on.
*/
SelfAlwaysShown,
/**
* Participants that are sharing their screen.
*/
Presenters, Presenters,
/**
* Participants that have been speaking recently.
*/
Speakers, Speakers,
/**
* Participants with both video and audio.
*/
VideoAndAudio, VideoAndAudio,
/**
* Participants with video but no audio.
*/
Video, Video,
/**
* Participants with audio but no video.
*/
Audio, Audio,
/**
* Participants not sharing any media.
*/
NoMedia, NoMedia,
SelfEnd, /**
* Yourself, when the "always show self" option is off.
*/
SelfNotAlwaysShown,
} }
class UserMedia { class UserMedia {
@@ -152,7 +178,10 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant, participant: LocalParticipant | RemoteParticipant,
callEncrypted: boolean, 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( this.speaker = this.vm.speaking.pipeState(
// Require 1 s of continuous speaking to become a speaker, and 60 s of // Require 1 s of continuous speaking to become a speaker, and 60 s of
@@ -372,7 +401,7 @@ export class CallViewModel extends ViewModel {
}, },
new Map<string, MediaItem>(), new Map<string, MediaItem>(),
), ),
map((ms) => [...ms.values()]), map((mediaItems) => [...mediaItems.values()]),
finalizeValue((ts) => { finalizeValue((ts) => {
for (const t of ts) t.destroy(); for (const t of ts) t.destroy();
}), }),
@@ -380,35 +409,41 @@ export class CallViewModel extends ViewModel {
); );
private readonly userMedia: Observable<UserMedia[]> = this.mediaItems.pipe( 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[]> = private readonly screenShares: Observable<ScreenShare[]> =
this.mediaItems.pipe( 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> = private readonly spotlightSpeaker: Observable<UserMedia | null> =
this.userMedia.pipe( this.userMedia.pipe(
switchMap((ms) => switchMap((mediaItems) =>
ms.length === 0 mediaItems.length === 0
? of([]) ? of([])
: combineLatest( : 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>( scan<(readonly [UserMedia, boolean])[], UserMedia | null, null>(
(prev, ms) => (prev, mediaItems) =>
// Decide who to spotlight: // Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather // If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else // 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 // 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 // Otherwise, stick with the person who was last speaking
prev ?? prev ??
// Otherwise, spotlight the local user // Otherwise, spotlight the local user
ms.find(([m]) => m.vm.local)?.[0] ?? mediaItems.find(([m]) => m.vm.local)?.[0] ??
null, null,
null, null,
), ),
@@ -417,13 +452,24 @@ export class CallViewModel extends ViewModel {
); );
private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe( private readonly grid: Observable<UserMediaViewModel[]> = this.userMedia.pipe(
switchMap((ms) => { switchMap((mediaItems) => {
const bins = ms.map((m) => const bins = mediaItems.map((m) =>
combineLatest( 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; 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 (presenter) bin = SortingBin.Presenters;
else if (speaker) bin = SortingBin.Speakers; else if (speaker) bin = SortingBin.Speakers;
else if (video) else if (video)
@@ -535,7 +581,19 @@ export class CallViewModel extends ViewModel {
const userMediaVm = const userMediaVm =
tilesById.get(userMediaId)?.data ?? 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); tilesById.delete(userMediaId);
const userMediaTile: TileDescriptor<MediaViewModel> = { const userMediaTile: TileDescriptor<MediaViewModel> = {

View File

@@ -49,6 +49,7 @@ import { useEffect } from "react";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { alwaysShowSelf } from "../settings/settings";
export interface NameData { export interface NameData {
/** /**
@@ -153,29 +154,14 @@ abstract class BaseMediaViewModel extends ViewModel {
* Some participant's media. * Some participant's media.
*/ */
export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel; export type MediaViewModel = UserMediaViewModel | ScreenShareViewModel;
export type UserMediaViewModel =
| LocalUserMediaViewModel
| RemoteUserMediaViewModel;
/** /**
* Some participant's user media. * Some participant's user media.
*/ */
export class UserMediaViewModel extends BaseMediaViewModel { abstract class BaseUserMediaViewModel 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"),
);
}),
),
);
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
@@ -186,19 +172,6 @@ export class UserMediaViewModel extends BaseMediaViewModel {
).pipe(map((p) => p.isSpeaking)), ).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). * Whether this participant is sending audio (i.e. is unmuted on their side).
*/ */
@@ -236,25 +209,90 @@ export class UserMediaViewModel extends BaseMediaViewModel {
this.videoEnabled = state( this.videoEnabled = state(
media.pipe(map((m) => m.cameraTrack?.isMuted === false)), media.pipe(map((m) => m.cameraTrack?.isMuted === false)),
); );
// 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);
});
}
public toggleLocallyMuted(): void {
this._locallyMuted.next(!this._locallyMuted.value);
} }
public toggleFitContain(): void { public toggleFitContain(): void {
this._cropVideo.next(!this._cropVideo.value); 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
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 setLocalVolume(value: number): void { public setLocalVolume(value: number): void {
this._localVolume.next(value); this._localVolume.next(value);

View File

@@ -14,7 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { ComponentProps, forwardRef, useCallback, useState } from "react"; import {
ComponentProps,
ReactNode,
forwardRef,
useCallback,
useState,
} from "react";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -24,6 +30,7 @@ import MicOffIcon from "@vector-im/compound-design-tokens/icons/mic-off.svg?reac
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react"; import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react";
import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react"; import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react";
import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.svg?react"; import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.svg?react";
import VisibilityOnIcon from "@vector-im/compound-design-tokens/icons/visibility-on.svg?react";
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react"; import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react"; import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react"; import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
@@ -33,7 +40,7 @@ import {
ToggleMenuItem, ToggleMenuItem,
Menu, Menu,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { useStateObservable } from "@react-rxjs/core"; import { useObservableEagerState } from "observable-hooks";
import styles from "./GridTile.module.css"; import styles from "./GridTile.module.css";
import { import {
@@ -41,71 +48,93 @@ import {
MediaViewModel, MediaViewModel,
UserMediaViewModel, UserMediaViewModel,
useNameData, useNameData,
LocalUserMediaViewModel,
RemoteUserMediaViewModel,
} from "../state/MediaViewModel"; } from "../state/MediaViewModel";
import { subscribe } from "../state/subscribe";
import { Slider } from "../Slider"; import { Slider } from "../Slider";
import { MediaView } from "./MediaView"; import { MediaView } from "./MediaView";
import { useLatest } from "../useLatest";
interface UserMediaTileProps { interface TileProps {
vm: UserMediaViewModel;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
targetHeight: number; targetHeight: number;
maximised: boolean; maximised: boolean;
onOpenProfile: () => void; displayName: string;
showSpeakingIndicator: boolean; nameTag: string;
} }
const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>( interface MediaTileProps
extends TileProps,
Omit<ComponentProps<typeof animated.div>, "className"> {
vm: MediaViewModel;
videoEnabled: boolean;
videoFit: "contain" | "cover";
nameTagLeadingIcon?: ReactNode;
primaryButton: ReactNode;
secondaryButton?: ReactNode;
}
const MediaTile = forwardRef<HTMLDivElement, MediaTileProps>(
({ vm, className, maximised, ...props }, ref) => {
const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
return (
<MediaView
ref={ref}
className={classNames(className, styles.tile)}
data-maximised={maximised}
video={video}
mirror={false}
member={vm.member}
unencryptedWarning={unencryptedWarning}
{...props}
/>
);
},
);
MediaTile.displayName = "MediaTile";
interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel;
showSpeakingIndicators: boolean;
menuStart?: ReactNode;
menuEnd?: ReactNode;
}
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
( (
{ {
vm, vm,
showSpeakingIndicators,
menuStart,
menuEnd,
className, className,
style, nameTag,
targetWidth, ...props
targetHeight,
maximised,
onOpenProfile,
showSpeakingIndicator,
}, },
ref, ref,
) => { ) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { displayName, nameTag } = useNameData(vm); const audioEnabled = useObservableEagerState(vm.audioEnabled);
const video = useStateObservable(vm.video); const videoEnabled = useObservableEagerState(vm.videoEnabled);
const audioEnabled = useStateObservable(vm.audioEnabled); const speaking = useObservableEagerState(vm.speaking);
const videoEnabled = useStateObservable(vm.videoEnabled); const cropVideo = useObservableEagerState(vm.cropVideo);
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
const mirror = useStateObservable(vm.mirror);
const speaking = useStateObservable(vm.speaking);
const locallyMuted = useStateObservable(vm.locallyMuted);
const cropVideo = useStateObservable(vm.cropVideo);
const localVolume = useStateObservable(vm.localVolume);
const onChangeMute = useCallback(() => vm.toggleLocallyMuted(), [vm]);
const onChangeFitContain = useCallback(() => vm.toggleFitContain(), [vm]); const onChangeFitContain = useCallback(() => vm.toggleFitContain(), [vm]);
const onSelectMute = useCallback((e: Event) => e.preventDefault(), []);
const onSelectFitContain = useCallback( const onSelectFitContain = useCallback(
(e: Event) => e.preventDefault(), (e: Event) => e.preventDefault(),
[], [],
); );
const onChangeLocalVolume = useCallback(
(v: number) => vm.setLocalVolume(v),
[vm],
);
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menu = vm.local ? ( const menu = (
<> <>
<MenuItem {menuStart}
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
<ToggleMenuItem <ToggleMenuItem
Icon={ExpandIcon} Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")} label={t("video_tile.change_fit_contain")}
@@ -113,55 +142,19 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
onChange={onChangeFitContain} onChange={onChangeFitContain}
onSelect={onSelectFitContain} onSelect={onSelectFitContain}
/> />
</> {menuEnd}
) : (
<>
<ToggleMenuItem
Icon={MicOffIcon}
label={t("video_tile.mute_for_me")}
checked={locallyMuted}
onChange={onChangeMute}
onSelect={onSelectMute}
/>
<ToggleMenuItem
Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")}
checked={cropVideo}
onChange={onChangeFitContain}
onSelect={onSelectFitContain}
/>
{/* TODO: Figure out how to make this slider keyboard accessible */}
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
<Slider
className={styles.volumeSlider}
label={t("video_tile.volume")}
value={localVolume}
onValueChange={onChangeLocalVolume}
min={0.1}
max={1}
step={0.01}
disabled={locallyMuted}
/>
</MenuItem>
</> </>
); );
const tile = ( const tile = (
<MediaView <MediaTile
ref={ref} ref={ref}
className={classNames(className, styles.tile, { vm={vm}
[styles.speaking]: showSpeakingIndicator && speaking,
})}
data-maximised={maximised}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
videoFit={cropVideo ? "cover" : "contain"}
mirror={mirror}
member={vm.member}
videoEnabled={videoEnabled} videoEnabled={videoEnabled}
unencryptedWarning={unencryptedWarning} videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, {
[styles.speaking]: showSpeakingIndicators && speaking,
})}
nameTagLeadingIcon={ nameTagLeadingIcon={
<MicIcon <MicIcon
width={20} width={20}
@@ -172,7 +165,6 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
/> />
} }
nameTag={nameTag} nameTag={nameTag}
displayName={displayName}
primaryButton={ primaryButton={
<Menu <Menu
open={menuOpen} open={menuOpen}
@@ -189,6 +181,7 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
{menu} {menu}
</Menu> </Menu>
} }
{...props}
/> />
); );
@@ -202,35 +195,121 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
UserMediaTile.displayName = "UserMediaTile"; UserMediaTile.displayName = "UserMediaTile";
interface ScreenShareTileProps { interface LocalUserMediaTileProps extends TileProps {
vm: LocalUserMediaViewModel;
onOpenProfile: () => void;
showSpeakingIndicators: boolean;
}
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
({ vm, onOpenProfile, className, ...props }, ref) => {
const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror);
const alwaysShow = useObservableEagerState(vm.alwaysShow);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => e.preventDefault(),
[],
);
const onChangeAlwaysShow = useCallback(
() => vm.setAlwaysShow(!latestAlwaysShow.current),
[vm, latestAlwaysShow],
);
return (
<UserMediaTile
ref={ref}
vm={vm}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onChange={onChangeAlwaysShow}
onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
}
className={classNames(className, { [styles.mirror]: mirror })}
{...props}
/>
);
},
);
LocalUserMediaTile.displayName = "LocalUserMediaTile";
interface RemoteUserMediaTileProps extends TileProps {
vm: RemoteUserMediaViewModel;
showSpeakingIndicators: boolean;
}
const RemoteUserMediaTile = forwardRef<
HTMLDivElement,
RemoteUserMediaTileProps
>(({ vm, ...props }, ref) => {
const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted);
const localVolume = useObservableEagerState(vm.localVolume);
const onChangeMute = useCallback(() => vm.toggleLocallyMuted(), [vm]);
const onSelectMute = useCallback((e: Event) => e.preventDefault(), []);
const onChangeLocalVolume = useCallback(
(v: number) => vm.setLocalVolume(v),
[vm],
);
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
return (
<UserMediaTile
ref={ref}
vm={vm}
menuStart={
<>
<ToggleMenuItem
Icon={MicOffIcon}
label={t("video_tile.mute_for_me")}
checked={locallyMuted}
onChange={onChangeMute}
onSelect={onSelectMute}
/>
{/* TODO: Figure out how to make this slider keyboard accessible */}
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
<Slider
className={styles.volumeSlider}
label={t("video_tile.volume")}
value={localVolume}
onValueChange={onChangeLocalVolume}
min={0.1}
max={1}
step={0.01}
disabled={locallyMuted}
/>
</MenuItem>
</>
}
{...props}
/>
);
});
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface ScreenShareTileProps extends TileProps {
vm: ScreenShareViewModel; vm: ScreenShareViewModel;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
maximised: boolean;
fullscreen: boolean; fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void; onToggleFullscreen: (itemId: string) => void;
} }
const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>( const ScreenShareTile = forwardRef<HTMLDivElement, ScreenShareTileProps>(
( ({ vm, fullscreen, onToggleFullscreen, ...props }, ref) => {
{
vm,
className,
style,
targetWidth,
targetHeight,
maximised,
fullscreen,
onToggleFullscreen,
},
ref,
) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { displayName, nameTag } = useNameData(vm);
const video = useStateObservable(vm.video);
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
const onClickFullScreen = useCallback( const onClickFullScreen = useCallback(
() => onToggleFullscreen(vm.id), () => onToggleFullscreen(vm.id),
[onToggleFullscreen, vm], [onToggleFullscreen, vm],
@@ -239,23 +318,10 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon; const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
return ( return (
<MediaView <MediaTile
ref={ref} ref={ref}
className={classNames(className, styles.tile, { vm={vm}
[styles.maximised]: maximised,
})}
data-maximised={maximised}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
videoFit="contain" videoFit="contain"
mirror={false}
member={vm.member}
videoEnabled
unencryptedWarning={unencryptedWarning}
nameTag={nameTag}
displayName={displayName}
primaryButton={ primaryButton={
!vm.local && ( !vm.local && (
<button <button
@@ -270,6 +336,8 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
</button> </button>
) )
} }
videoEnabled
{...props}
/> />
); );
}, },
@@ -277,7 +345,7 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
ScreenShareTile.displayName = "ScreenShareTile"; ScreenShareTile.displayName = "ScreenShareTile";
interface Props { interface GridTileProps {
vm: MediaViewModel; vm: MediaViewModel;
maximised: boolean; maximised: boolean;
fullscreen: boolean; fullscreen: boolean;
@@ -287,51 +355,34 @@ interface Props {
targetHeight: number; targetHeight: number;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicator: boolean; showSpeakingIndicators: boolean;
} }
export const GridTile = forwardRef<HTMLDivElement, Props>( export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
( ({ vm, fullscreen, onToggleFullscreen, onOpenProfile, ...props }, ref) => {
{ const nameData = useNameData(vm);
vm,
maximised, if (vm instanceof LocalUserMediaViewModel) {
fullscreen,
onToggleFullscreen,
onOpenProfile,
className,
style,
targetWidth,
targetHeight,
showSpeakingIndicator,
},
ref,
) => {
if (vm instanceof UserMediaViewModel) {
return ( return (
<UserMediaTile <LocalUserMediaTile
ref={ref} ref={ref}
className={className}
style={style}
vm={vm} vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
maximised={maximised}
onOpenProfile={onOpenProfile} onOpenProfile={onOpenProfile}
showSpeakingIndicator={showSpeakingIndicator} {...props}
{...nameData}
/> />
); );
} else if (vm instanceof RemoteUserMediaViewModel) {
return <RemoteUserMediaTile ref={ref} vm={vm} {...props} {...nameData} />;
} else { } else {
return ( return (
<ScreenShareTile <ScreenShareTile
ref={ref} ref={ref}
className={className}
style={style}
vm={vm} vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
maximised={maximised}
fullscreen={fullscreen} fullscreen={fullscreen}
onToggleFullscreen={onToggleFullscreen} onToggleFullscreen={onToggleFullscreen}
{...props}
{...nameData}
/> />
); );
} }

View File

@@ -37,8 +37,9 @@ import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css"; import styles from "./SpotlightTile.module.css";
import { subscribe } from "../state/subscribe"; import { subscribe } from "../state/subscribe";
import { import {
LocalUserMediaViewModel,
MediaViewModel, MediaViewModel,
UserMediaViewModel, RemoteUserMediaViewModel,
useNameData, useNameData,
} from "../state/MediaViewModel"; } from "../state/MediaViewModel";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
@@ -48,11 +49,11 @@ import { useReactiveState } from "../useReactiveState";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
// Screen share video is always enabled // Screen share video is always enabled
const screenShareVideoEnabled = state(of(true)); const videoEnabledDefault = state(of(true));
// Never mirror screen share video // Never mirror screen share video
const screenShareMirror = state(of(false)); const mirrorDefault = state(of(false));
// Never crop screen share video // Never crop screen share video
const screenShareCropVideo = state(of(false)); const cropVideoDefault = state(of(false));
interface SpotlightItemProps { interface SpotlightItemProps {
vm: MediaViewModel; vm: MediaViewModel;
@@ -72,15 +73,19 @@ const SpotlightItem = subscribe<SpotlightItemProps, HTMLDivElement>(
const { displayName, nameTag } = useNameData(vm); const { displayName, nameTag } = useNameData(vm);
const video = useStateObservable(vm.video); const video = useStateObservable(vm.video);
const videoEnabled = useStateObservable( const videoEnabled = useStateObservable(
vm instanceof UserMediaViewModel vm instanceof LocalUserMediaViewModel ||
vm instanceof RemoteUserMediaViewModel
? vm.videoEnabled ? vm.videoEnabled
: screenShareVideoEnabled, : videoEnabledDefault,
); );
const mirror = useStateObservable( const mirror = useStateObservable(
vm instanceof UserMediaViewModel ? vm.mirror : screenShareMirror, vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault,
); );
const cropVideo = useStateObservable( const cropVideo = useStateObservable(
vm instanceof UserMediaViewModel ? vm.cropVideo : screenShareCropVideo, vm instanceof LocalUserMediaViewModel ||
vm instanceof RemoteUserMediaViewModel
? vm.cropVideo
: cropVideoDefault,
); );
const unencryptedWarning = useStateObservable(vm.unencryptedWarning); const unencryptedWarning = useStateObservable(vm.unencryptedWarning);