Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@
"muted_for_me": "Muted for me",
"screen_share_volume": "Screen share volume",
"volume": "Volume",
"waiting_for_media": "Waiting for media..."
"waiting_for_media": "Waiting for media...",
"pin_speaker": "Pin the speaker",
"unpin_speaker": "Detach the speaker"
}
}
}
6 changes: 4 additions & 2 deletions locales/ru/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@
"mute_for_me": "Заглушить звук для меня",
"muted_for_me": "Приглушить для меня",
"volume": "Громкость",
"waiting_for_media": "В ожидании медиа..."
"waiting_for_media": "В ожидании медиа...",
"pin_speaker": "Закрепить спикера",
"unpin_speaker": "Открепить спикера"
}
}
}
5 changes: 4 additions & 1 deletion src/room/InCallView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export const InCallView: FC<InCallViewProps> = ({
const showSpotlightIndicatorsValue = useBehavior(
vm.showSpotlightIndicators$,
);
const pinnedUserId = useBehavior(vm.pinnedUserId$);

return model instanceof GridTileViewModel ? (
<GridTile
Expand All @@ -466,6 +467,8 @@ export const InCallView: FC<InCallViewProps> = ({
style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue}
focusable={!contentObscured}
onPinUser={(userId) => vm.pinSpotlightUser(userId)}
pinnedUserId={pinnedUserId}
/>
) : (
<SpotlightTile
Expand Down Expand Up @@ -656,4 +659,4 @@ export const InCallView: FC<InCallViewProps> = ({
)}
</div>
);
};
};
90 changes: 58 additions & 32 deletions src/state/CallViewModel/CallViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,16 @@ export interface CallViewModel {
*/
unhoverScreen: () => void;

/**
* Pin a specific user to the spotlight. Pass null to unpin and return to auto-speaker detection.
*/
pinSpotlightUser: (userId: string | null) => void;

/**
* The currently pinned user ID, or null if no user is pinned.
*/
pinnedUserId$: Behavior<string | null>;

// errors
/**
* If there is a configuration error with the call (e.g. misconfigured E2EE).
Expand Down Expand Up @@ -886,40 +896,54 @@ export function createCallViewModel$(
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
).pipe(scope.share);

const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
userMedia$.pipe(
switchMap((mediaItems) =>
mediaItems.length === 0
? of([])
: combineLatest(
mediaItems.map((m) =>
const pinnedUserId$ = new Subject<string | null>();
const pinnedUserIdBehavior$ = scope.behavior(
pinnedUserId$.pipe(startWith(null))
);

const pinnedSpeaker$ = combineLatest([userMedia$, pinnedUserIdBehavior$]).pipe(
map(([mediaItems, pinnedUserId]): UserMediaViewModel | null => {
if (pinnedUserId !== null) {
return mediaItems.find((m) => m.userId === pinnedUserId) ?? null;
}
return null;
}),
);

const autoSpeaker$ = userMedia$.pipe(
switchMap((mediaItems) => {
if (mediaItems.length === 0) return of(undefined);

return combineLatest(
mediaItems.map((m) =>
m.speaking$.pipe(map((s) => [m, s] as const)),
),
),
).pipe(
scan<
(readonly [UserMediaViewModel, boolean])[],
UserMediaViewModel | undefined,
undefined
>((prev, items) => {
const [stickyMedia, stickySpeaking] =
(!prev?.local && items.find(([m]) => m === prev)) || [];

return stickySpeaking
? stickyMedia!
: items.find(([m, s]) => !m.local && s)?.[0] ?? // Говорящий удалённый
stickyMedia ?? // Последний говоривший
items.find(([m]) => !m.local)?.[0] ?? // Любой удалённый
items.find(([m]) => m.local)?.[0]; // Локальный
}, undefined),
);
}),
);

const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
combineLatest([pinnedSpeaker$, autoSpeaker$]).pipe(
map(([pinned, auto]) => {
return pinned ?? auto;
}),
),
scan<
(readonly [UserMediaViewModel, boolean])[],
UserMediaViewModel | undefined,
undefined
>((prev, mediaItems) => {
// Only remote users that are still in the call should be sticky
const [stickyMedia, stickySpeaking] =
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
return stickySpeaking
? stickyMedia!
: // Otherwise, select any remote user who is speaking
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking
stickyMedia ??
// Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.local)?.[0] ??
// Otherwise, spotlight the local user
mediaItems.find(([m]) => m.local)?.[0]);
}, undefined),
),
);

const grid$ = scope.behavior<UserMediaViewModel[]>(
Expand Down Expand Up @@ -1560,6 +1584,8 @@ export function createCallViewModel$(
hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish,
leave: localMembership.requestDisconnect,
pinSpotlightUser: (userId: string | null) => pinnedUserId$.next(userId),
pinnedUserId$: pinnedUserIdBehavior$,
toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$,

Expand Down Expand Up @@ -1647,4 +1673,4 @@ function getE2eeKeyProvider(
.catch((e) => logger.error("Failed to set shared key for E2EE", e));
return keyProvider;
}
}
}
35 changes: 34 additions & 1 deletion src/tile/GridTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
VideoCallSolidIcon,
VoiceCallSolidIcon,
EndCallIcon,
PinIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ContextMenu,
Expand Down Expand Up @@ -328,18 +329,24 @@ LocalUserMediaTile.displayName = "LocalUserMediaTile";
interface RemoteUserMediaTileProps extends TileProps {
vm: RemoteUserMediaViewModel;
showSpeakingIndicators: boolean;
onPinUser?: (userId: string | null) => void;
pinnedUserId: string | null;
}

const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
ref,
vm,
onPinUser,
pinnedUserId,
...props
}) => {
const { t } = useTranslation();
const waitingForMedia = useBehavior(vm.waitingForMedia$);
const playbackMuted = useBehavior(vm.playbackMuted$);
const playbackVolume = useBehavior(vm.playbackVolume$);
const focusUrl = useBehavior(vm.focusUrl$);
const userId = vm.userId;
const isPinned = pinnedUserId === userId

const onSelectMute = useCallback(
(e: Event) => {
Expand All @@ -349,6 +356,17 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
[vm],
);

const onSelectPin = useCallback(
(e: Event) => {
e.preventDefault();

if (onPinUser) {
onPinUser(isPinned ? null : userId);
}
},
[onPinUser, userId, isPinned],
);

const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon;

return (
Expand All @@ -360,6 +378,12 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
mirror={false}
menuStart={
<>
<ToggleMenuItem
Icon={PinIcon}
label={isPinned ? t("video_tile.unpin_speaker") : t("video_tile.pin_speaker")}
checked={isPinned}
onSelect={onSelectPin}
/>
<ToggleMenuItem
Icon={MicOffIcon}
label={t("video_tile.mute_for_me")}
Expand Down Expand Up @@ -399,13 +423,17 @@ interface GridTileProps {
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean;
focusable: boolean;
onPinUser?: (userId: string | null) => void;
pinnedUserId: string | null;
}

export const GridTile: FC<GridTileProps> = ({
ref: theirRef,
vm,
showSpeakingIndicators,
onOpenProfile,
onPinUser,
pinnedUserId,
...props
}) => {
const ourRef = useRef<HTMLDivElement | null>(null);
Expand All @@ -422,6 +450,7 @@ export const GridTile: FC<GridTileProps> = ({
{...props}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
pinnedUserId={pinnedUserId}
/>
);
} else if (media.local) {
Expand All @@ -433,6 +462,8 @@ export const GridTile: FC<GridTileProps> = ({
onOpenProfile={onOpenProfile}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
onPinUser={onPinUser}
pinnedUserId={pinnedUserId}
{...props}
/>
);
Expand All @@ -444,10 +475,12 @@ export const GridTile: FC<GridTileProps> = ({
showSpeakingIndicators={showSpeakingIndicators}
displayName={displayName}
mxcAvatarUrl={mxcAvatarUrl}
onPinUser={onPinUser}
pinnedUserId={pinnedUserId}
{...props}
/>
);
}
};

GridTile.displayName = "GridTile";
GridTile.displayName = "GridTile";
Loading