Split local and remote user media into different classes
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -151,7 +153,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
|
||||||
@@ -520,7 +525,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> = {
|
||||||
|
|||||||
@@ -153,29 +153,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 +171,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 +208,83 @@ 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"),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -33,7 +39,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 +47,92 @@ 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";
|
||||||
|
|
||||||
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 +140,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 +163,6 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
nameTag={nameTag}
|
nameTag={nameTag}
|
||||||
displayName={displayName}
|
|
||||||
primaryButton={
|
primaryButton={
|
||||||
<Menu
|
<Menu
|
||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
@@ -189,6 +179,7 @@ const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
|
|||||||
{menu}
|
{menu}
|
||||||
</Menu>
|
</Menu>
|
||||||
}
|
}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -202,35 +193,102 @@ 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserMediaTile
|
||||||
|
ref={ref}
|
||||||
|
vm={vm}
|
||||||
|
menuStart={
|
||||||
|
<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 +297,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 +315,8 @@ const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
videoEnabled
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -277,7 +324,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 +334,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user