diff --git a/locales/en/app.json b/locales/en/app.json index 5398930f2..e5b3eb73e 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -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" } -} +} \ No newline at end of file diff --git a/locales/ru/app.json b/locales/ru/app.json index 651d119d4..2c679da97 100644 --- a/locales/ru/app.json +++ b/locales/ru/app.json @@ -218,6 +218,8 @@ "mute_for_me": "Заглушить звук для меня", "muted_for_me": "Приглушить для меня", "volume": "Громкость", - "waiting_for_media": "В ожидании медиа..." + "waiting_for_media": "В ожидании медиа...", + "pin_speaker": "Закрепить спикера", + "unpin_speaker": "Открепить спикера" } -} +} \ No newline at end of file diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 4940f4d83..fc6d8b6ac 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -454,6 +454,7 @@ export const InCallView: FC = ({ const showSpotlightIndicatorsValue = useBehavior( vm.showSpotlightIndicators$, ); + const pinnedUserId = useBehavior(vm.pinnedUserId$); return model instanceof GridTileViewModel ? ( = ({ style={style} showSpeakingIndicators={showSpeakingIndicatorsValue} focusable={!contentObscured} + onPinUser={(userId) => vm.pinSpotlightUser(userId)} + pinnedUserId={pinnedUserId} /> ) : ( = ({ )} ); -}; +}; \ No newline at end of file diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index e298bcfdb..16af0d1d4 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -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; + // errors /** * If there is a configuration error with the call (e.g. misconfigured E2EE). @@ -886,40 +896,54 @@ export function createCallViewModel$( merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)), ).pipe(scope.share); - const spotlightSpeaker$ = scope.behavior( - userMedia$.pipe( - switchMap((mediaItems) => - mediaItems.length === 0 - ? of([]) - : combineLatest( - mediaItems.map((m) => + const pinnedUserId$ = new Subject(); + 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( + 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( @@ -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$, @@ -1647,4 +1673,4 @@ function getE2eeKeyProvider( .catch((e) => logger.error("Failed to set shared key for E2EE", e)); return keyProvider; } -} +} \ No newline at end of file diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 13cf677f3..7dfb710a9 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -32,6 +32,7 @@ import { VideoCallSolidIcon, VoiceCallSolidIcon, EndCallIcon, + PinIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { ContextMenu, @@ -328,11 +329,15 @@ LocalUserMediaTile.displayName = "LocalUserMediaTile"; interface RemoteUserMediaTileProps extends TileProps { vm: RemoteUserMediaViewModel; showSpeakingIndicators: boolean; + onPinUser?: (userId: string | null) => void; + pinnedUserId: string | null; } const RemoteUserMediaTile: FC = ({ ref, vm, + onPinUser, + pinnedUserId, ...props }) => { const { t } = useTranslation(); @@ -340,6 +345,8 @@ const RemoteUserMediaTile: FC = ({ 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) => { @@ -349,6 +356,17 @@ const RemoteUserMediaTile: FC = ({ [vm], ); + const onSelectPin = useCallback( + (e: Event) => { + e.preventDefault(); + + if (onPinUser) { + onPinUser(isPinned ? null : userId); + } + }, + [onPinUser, userId, isPinned], + ); + const VolumeIcon = playbackMuted ? VolumeOffIcon : VolumeOnIcon; return ( @@ -360,6 +378,12 @@ const RemoteUserMediaTile: FC = ({ mirror={false} menuStart={ <> + ["style"]; showSpeakingIndicators: boolean; focusable: boolean; + onPinUser?: (userId: string | null) => void; + pinnedUserId: string | null; } export const GridTile: FC = ({ @@ -406,6 +432,8 @@ export const GridTile: FC = ({ vm, showSpeakingIndicators, onOpenProfile, + onPinUser, + pinnedUserId, ...props }) => { const ourRef = useRef(null); @@ -422,6 +450,7 @@ export const GridTile: FC = ({ {...props} displayName={displayName} mxcAvatarUrl={mxcAvatarUrl} + pinnedUserId={pinnedUserId} /> ); } else if (media.local) { @@ -433,6 +462,8 @@ export const GridTile: FC = ({ onOpenProfile={onOpenProfile} displayName={displayName} mxcAvatarUrl={mxcAvatarUrl} + onPinUser={onPinUser} + pinnedUserId={pinnedUserId} {...props} /> ); @@ -444,10 +475,12 @@ export const GridTile: FC = ({ showSpeakingIndicators={showSpeakingIndicators} displayName={displayName} mxcAvatarUrl={mxcAvatarUrl} + onPinUser={onPinUser} + pinnedUserId={pinnedUserId} {...props} /> ); } }; -GridTile.displayName = "GridTile"; +GridTile.displayName = "GridTile"; \ No newline at end of file