diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 95bf334f..f40c7adc 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -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", diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 1ccdb2ec..f2e08e5c 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -384,7 +384,7 @@ export const InCallView: FC = subscribe( targetHeight={targetHeight} className={className} style={style} - showSpeakingIndicator={showSpeakingIndicators} + showSpeakingIndicators={showSpeakingIndicators} /> ); }, @@ -424,7 +424,7 @@ export const InCallView: FC = subscribe( targetHeight={gridBounds.height} targetWidth={gridBounds.width} key={maximisedParticipant.id} - showSpeakingIndicator={false} + showSpeakingIndicators={false} onOpenProfile={openProfile} /> ); diff --git a/src/settings/settings.ts b/src/settings/settings.ts index 5f2cc529..9c181ccf 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -90,3 +90,5 @@ export const videoInput = new Setting( "video-input", undefined, ); + +export const alwaysShowSelf = new Setting("always-show-self", true); diff --git a/src/state/CallViewModel.ts b/src/state/CallViewModel.ts index 59f0f5dc..8fc2dc95 100644 --- a/src/state/CallViewModel.ts +++ b/src/state/CallViewModel.ts @@ -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"; @@ -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 { @@ -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 @@ -372,7 +401,7 @@ export class CallViewModel extends ViewModel { }, new Map(), ), - map((ms) => [...ms.values()]), + map((mediaItems) => [...mediaItems.values()]), finalizeValue((ts) => { for (const t of ts) t.destroy(); }), @@ -380,35 +409,41 @@ export class CallViewModel extends ViewModel { ); private readonly userMedia: Observable = 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 = 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 = 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, ), @@ -417,13 +452,24 @@ export class CallViewModel extends ViewModel { ); private readonly grid: Observable = 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) @@ -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 = { diff --git a/src/state/MediaViewModel.ts b/src/state/MediaViewModel.ts index f1e772da..c61b5255 100644 --- a/src/state/MediaViewModel.ts +++ b/src/state/MediaViewModel.ts @@ -49,6 +49,7 @@ import { useEffect } from "react"; import { ViewModel } from "./ViewModel"; import { useReactiveState } from "../useReactiveState"; +import { alwaysShowSelf } from "../settings/settings"; export interface NameData { /** @@ -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. */ @@ -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). */ @@ -236,25 +209,90 @@ export class UserMediaViewModel extends BaseMediaViewModel { this.videoEnabled = state( 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 { 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 { this._localVolume.next(value); diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index d88b189f..ba615953 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -14,7 +14,13 @@ See the License for the specific language governing permissions and 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 classNames from "classnames"; 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 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 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 ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react"; import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react"; @@ -33,7 +40,7 @@ import { ToggleMenuItem, Menu, } from "@vector-im/compound-web"; -import { useStateObservable } from "@react-rxjs/core"; +import { useObservableEagerState } from "observable-hooks"; import styles from "./GridTile.module.css"; import { @@ -41,71 +48,93 @@ import { MediaViewModel, UserMediaViewModel, useNameData, + LocalUserMediaViewModel, + RemoteUserMediaViewModel, } from "../state/MediaViewModel"; -import { subscribe } from "../state/subscribe"; import { Slider } from "../Slider"; import { MediaView } from "./MediaView"; +import { useLatest } from "../useLatest"; -interface UserMediaTileProps { - vm: UserMediaViewModel; +interface TileProps { className?: string; style?: ComponentProps["style"]; targetWidth: number; targetHeight: number; maximised: boolean; - onOpenProfile: () => void; - showSpeakingIndicator: boolean; + displayName: string; + nameTag: string; } -const UserMediaTile = subscribe( +interface MediaTileProps + extends TileProps, + Omit, "className"> { + vm: MediaViewModel; + videoEnabled: boolean; + videoFit: "contain" | "cover"; + nameTagLeadingIcon?: ReactNode; + primaryButton: ReactNode; + secondaryButton?: ReactNode; +} + +const MediaTile = forwardRef( + ({ vm, className, maximised, ...props }, ref) => { + const video = useObservableEagerState(vm.video); + const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); + + return ( + + ); + }, +); + +MediaTile.displayName = "MediaTile"; + +interface UserMediaTileProps extends TileProps { + vm: UserMediaViewModel; + showSpeakingIndicators: boolean; + menuStart?: ReactNode; + menuEnd?: ReactNode; +} + +const UserMediaTile = forwardRef( ( { vm, + showSpeakingIndicators, + menuStart, + menuEnd, className, - style, - targetWidth, - targetHeight, - maximised, - onOpenProfile, - showSpeakingIndicator, + nameTag, + ...props }, ref, ) => { const { t } = useTranslation(); - const { displayName, nameTag } = useNameData(vm); - const video = useStateObservable(vm.video); - const audioEnabled = useStateObservable(vm.audioEnabled); - const videoEnabled = useStateObservable(vm.videoEnabled); - 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 audioEnabled = useObservableEagerState(vm.audioEnabled); + const videoEnabled = useObservableEagerState(vm.videoEnabled); + const speaking = useObservableEagerState(vm.speaking); + const cropVideo = useObservableEagerState(vm.cropVideo); const onChangeFitContain = useCallback(() => vm.toggleFitContain(), [vm]); - const onSelectMute = useCallback((e: Event) => e.preventDefault(), []); const onSelectFitContain = useCallback( (e: Event) => e.preventDefault(), [], ); - const onChangeLocalVolume = useCallback( - (v: number) => vm.setLocalVolume(v), - [vm], - ); - const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; - const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon; const [menuOpen, setMenuOpen] = useState(false); - const menu = vm.local ? ( + const menu = ( <> - + {menuStart} ( onChange={onChangeFitContain} onSelect={onSelectFitContain} /> - - ) : ( - <> - - - {/* TODO: Figure out how to make this slider keyboard accessible */} - - - + {menuEnd} ); const tile = ( - ( /> } nameTag={nameTag} - displayName={displayName} primaryButton={ ( {menu} } + {...props} /> ); @@ -202,35 +195,121 @@ const UserMediaTile = subscribe( UserMediaTile.displayName = "UserMediaTile"; -interface ScreenShareTileProps { +interface LocalUserMediaTileProps extends TileProps { + vm: LocalUserMediaViewModel; + onOpenProfile: () => void; + showSpeakingIndicators: boolean; +} + +const LocalUserMediaTile = forwardRef( + ({ 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 ( + + } + menuEnd={ + + } + 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 ( + + + {/* TODO: Figure out how to make this slider keyboard accessible */} + + + + + } + {...props} + /> + ); +}); + +RemoteUserMediaTile.displayName = "RemoteUserMediaTile"; + +interface ScreenShareTileProps extends TileProps { vm: ScreenShareViewModel; - className?: string; - style?: ComponentProps["style"]; - targetWidth: number; - targetHeight: number; - maximised: boolean; fullscreen: boolean; onToggleFullscreen: (itemId: string) => void; } -const ScreenShareTile = subscribe( - ( - { - vm, - className, - style, - targetWidth, - targetHeight, - maximised, - fullscreen, - onToggleFullscreen, - }, - ref, - ) => { +const ScreenShareTile = forwardRef( + ({ vm, fullscreen, onToggleFullscreen, ...props }, ref) => { const { t } = useTranslation(); - const { displayName, nameTag } = useNameData(vm); - const video = useStateObservable(vm.video); - const unencryptedWarning = useStateObservable(vm.unencryptedWarning); const onClickFullScreen = useCallback( () => onToggleFullscreen(vm.id), [onToggleFullscreen, vm], @@ -239,23 +318,10 @@ const ScreenShareTile = subscribe( const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon; return ( - ( ) } + videoEnabled + {...props} /> ); }, @@ -277,7 +345,7 @@ const ScreenShareTile = subscribe( ScreenShareTile.displayName = "ScreenShareTile"; -interface Props { +interface GridTileProps { vm: MediaViewModel; maximised: boolean; fullscreen: boolean; @@ -287,51 +355,34 @@ interface Props { targetHeight: number; className?: string; style?: ComponentProps["style"]; - showSpeakingIndicator: boolean; + showSpeakingIndicators: boolean; } -export const GridTile = forwardRef( - ( - { - vm, - maximised, - fullscreen, - onToggleFullscreen, - onOpenProfile, - className, - style, - targetWidth, - targetHeight, - showSpeakingIndicator, - }, - ref, - ) => { - if (vm instanceof UserMediaViewModel) { +export const GridTile = forwardRef( + ({ vm, fullscreen, onToggleFullscreen, onOpenProfile, ...props }, ref) => { + const nameData = useNameData(vm); + + if (vm instanceof LocalUserMediaViewModel) { return ( - ); + } else if (vm instanceof RemoteUserMediaViewModel) { + return ; } else { return ( ); } diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index 6abf0cdd..e4bb085d 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -37,8 +37,9 @@ import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; import { subscribe } from "../state/subscribe"; import { + LocalUserMediaViewModel, MediaViewModel, - UserMediaViewModel, + RemoteUserMediaViewModel, useNameData, } from "../state/MediaViewModel"; import { useInitial } from "../useInitial"; @@ -48,11 +49,11 @@ import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; // Screen share video is always enabled -const screenShareVideoEnabled = state(of(true)); +const videoEnabledDefault = state(of(true)); // Never mirror screen share video -const screenShareMirror = state(of(false)); +const mirrorDefault = state(of(false)); // Never crop screen share video -const screenShareCropVideo = state(of(false)); +const cropVideoDefault = state(of(false)); interface SpotlightItemProps { vm: MediaViewModel; @@ -72,15 +73,19 @@ const SpotlightItem = subscribe( const { displayName, nameTag } = useNameData(vm); const video = useStateObservable(vm.video); const videoEnabled = useStateObservable( - vm instanceof UserMediaViewModel + vm instanceof LocalUserMediaViewModel || + vm instanceof RemoteUserMediaViewModel ? vm.videoEnabled - : screenShareVideoEnabled, + : videoEnabledDefault, ); const mirror = useStateObservable( - vm instanceof UserMediaViewModel ? vm.mirror : screenShareMirror, + vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault, ); const cropVideo = useStateObservable( - vm instanceof UserMediaViewModel ? vm.cropVideo : screenShareCropVideo, + vm instanceof LocalUserMediaViewModel || + vm instanceof RemoteUserMediaViewModel + ? vm.cropVideo + : cropVideoDefault, ); const unencryptedWarning = useStateObservable(vm.unencryptedWarning);