diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 607e7229f..023beb669 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index 183b1567e..b41c21756 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/components/calls/group/GroupCall.module.scss b/src/components/calls/group/GroupCall.module.scss new file mode 100644 index 000000000..1857dfa94 --- /dev/null +++ b/src/components/calls/group/GroupCall.module.scss @@ -0,0 +1,296 @@ +.root { + --group-call-panel-color: #212121; + --group-call-panel-header-border-color: #3b3b3b; + --group-call-background-color: #000000; + --green-button-color: rgba(1, 200, 80, 0.3); + --blue-button-color: rgb(60, 135, 247, 0.2); + --purple-button-color: rgb(61, 82, 223, 0.2); + --gradient-blue: linear-gradient(225deg, #4EABF8 14.73%, #3478F6 85.27%); + --gradient-green: linear-gradient(230.46deg, #00A3B4 12.94%, #00CB47 86.29%); + --gradient-purple: linear-gradient(230.46deg, #CE4D74 0%, #3D52DF 100%); + --gradient-speaking: linear-gradient(135deg, #5CC85C 0%, #48A1B3 101.27%); + + --red-button-color: rgba(255, 89, 90, 0.3); + --disabled-button-color: #333333; + --color-text-secondary: #AAAAAA; + --color-text: #FFFFFF; + + --default-width: 26.25rem; + --max-height: 40rem; + + color: var(--color-text); + + :global { + .modal-dialog { + max-width: var(--default-width); + max-height: min(var(--max-height), 100vh); + height: 100%; + min-height: min(80vh, var(--max-height)); + overflow: hidden; + background: var(--group-call-background-color); + } + + .modal-content { + min-height: 100%; + + display: flex; + + padding: 0; + } + } +} + +.panelWrapper { + max-width: var(--default-width); + width: 100%; +} + +.panel { + background: var(--group-call-panel-color); + + display: flex; + flex-direction: column; + height: 100%; + + overflow: auto; + position: relative; +} + +.panelScrollTrigger { + position: absolute; + top: 0; + width: 100%; +} + +.panelHeader { + display: flex; + align-items: center; + position: sticky; + top: 0; + + border-bottom: 0.0625rem solid transparent; + + padding: 0.375rem 0.875rem; + user-select: none; + z-index: 1; + background: var(--group-call-panel-color); + + transition: 0.25s ease-in-out border-bottom-color; + + &.scrolled { + border-bottom-color: var(--group-call-panel-header-border-color); + } +} + +.headerButton { + color: var(--color-text) !important; +} + +.firstButton { + margin-right: 1.375rem; +} + +.lastButton { + margin-left: auto; +} + +.panelHeaderText { + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; +} + +.title { + line-height: 1.375rem; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; + unicode-bidi: plaintext; + font-size: 1rem; + font-weight: 500; + margin: 0; +} + +.bigger { + font-size: 1.25rem; +} + +.subtitle { + font-size: 0.875rem; + line-height: 1.125rem; + margin: 0; + color: var(--color-text-secondary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: inline-block; +} + +.participants { + position: relative; + margin: 0.125rem 0.5rem 0; +} + +.participantVideos { + width: 100%; + position: relative; +} + +.addParticipantButton { + position: fixed; +} + +.videos { + display: flex; + flex-direction: column; + + width: calc(100% - var(--default-width)); +} + +.videosHeader { + display: flex; + align-items: center; + padding: 0.375rem 0.875rem; +} + +.videosHeaderLastButton { + margin-left: auto; +} + +.videosContent { + flex-grow: 1; + margin: 0.5rem 0.625rem; +} + +.actions { + --actions-max-width: 0px; + position: absolute; + left: 50%; + transform: translateX(calc(-50% - var(--actions-max-width) / 2)); + bottom: 1.75rem; + + display: flex; + gap: 1.25rem; + z-index: 2; +} + + +.actionButton { + width: 3.375rem !important; + height: 3.375rem !important; + color: var(--color-text) !important; + background-color: var(--green-button-color) !important; + transition: 0.15s filter, 0.25s ease-out background-color; + backdrop-filter: blur(25px); + + &:global(.disabled) { + background: var(--disabled-button-color) !important; + } + + &:hover { + filter: brightness(1.1); + } +} + +.destructive { + background: var(--red-button-color) !important; +} + +.canRequestToSpeak { + background: var(--purple-button-color) !important; +} + +.muted { + background: var(--blue-button-color) !important; +} + +.fullscreen { + :global { + .modal-dialog { + min-width: 100%; + min-height: 100%; + border-radius: 0; + } + + .modal-content { + max-height: initial; + } + } + + &.portrait .panelWrapper { + max-width: 100%; + } +} + +.landscape { + .panelWrapper { + position: absolute; + right: 0; + top: 0; + bottom: 0; + + transform: translateZ(0); + } + + &:not(.noVideoParticipants) { + .actions { + --actions-max-width: var(--default-width); + + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(50px); + border-radius: 1.25rem; + padding: 0.75rem; + bottom: 2.5rem; + transition: var(--layer-transition) transform, 250ms ease-in-out opacity; + opacity: 0; + } + + .videos:hover ~ .actions, .video:hover ~ .actions, .actions:hover { + opacity: 1; + } + } + + &.noVideoParticipants { + .panelWrapper { + max-width: max(50vw, 30rem); + width: 100%; + transform: translateX(-50%); + left: 50%; + right: 0; + } + + :global(.modal-content) { + background: var(--group-call-panel-color); + } + } +} + +.portrait { + .panelWrapper::after { + display: block; + content: ''; + position: fixed; + width: 100%; + height: 7.5rem; + left: 0; + bottom: 0; + pointer-events: none; + + background: linear-gradient(180deg, rgba(33, 33, 33, 0) 0%, rgba(33, 33, 33, 0.65) 48.54%, #212121 100%); + } +} + +.noSidebar { + .panelWrapper { + transform: translate3d(100%, 0, 0); + } + + .videos { + width: 100%; + } + + .actions { + --actions-max-width: 0px !important; + } +} diff --git a/src/components/calls/group/GroupCall.scss b/src/components/calls/group/GroupCall.scss deleted file mode 100644 index f2735ffd3..000000000 --- a/src/components/calls/group/GroupCall.scss +++ /dev/null @@ -1,345 +0,0 @@ -.GroupCall { - .modal-content { - display: flex; - flex-direction: column; - align-items: center; - height: 37.5rem; - } - - .modal-dialog { - max-height: calc(100% - 4rem); - background: #181f27; - } - - .Menu { - --color-text: white; - --color-background-compact-menu: #212121DD; - --color-background-compact-menu-hover: #00000066; - .bubble { - box-shadow: 0 0.25rem 0.5rem 0.125rem rgba(16, 16, 16, 0.3); - } - } - - &.single-column { - opacity: 1 !important; - - .modal-dialog { - max-width: 100% !important; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - margin-top: auto; - margin-bottom: 0; - transform: translate3d(0, 100%, 0); - transition: transform 0.3s ease, opacity 0.3s ease; - } - - .modal-backdrop { - opacity: 0; - transition: opacity 0.2s ease; - } - - &.open { - .modal-backdrop { - opacity: 1; - } - - .modal-dialog { - transform: translate3d(0, 0, 0); - } - } - } - - .header { - width: 100%; - display: flex; - align-items: center; - color: #fff; - margin-bottom: 0.5rem; - - h3 { - font-size: 1.25rem; - font-weight: 500; - margin: 0 auto 0 0.5rem; - } - } - - .videos { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - } - - .participants { - margin-top: 0.75rem; - background: #222b34; - border-radius: 0.75rem; - - .Loading { - padding: 2rem 0; - } - - .invite-btn { - padding: 0.25rem 0.75rem; - display: flex; - align-items: center; - border-radius: 0.75rem; - transition: 0.15s ease-out background-color; - cursor: var(--custom-cursor, pointer); - color: var(--color-text-secondary); - - &:hover { - background: #2f363e; - } - - .text { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - - .icon-wrapper { - display: flex; - justify-content: center; - align-items: center; - width: 2.75rem; - height: 2.75rem; - font-size: 1.5rem; - margin-right: 1rem; - } - } - } - - .scrollable { - overflow: auto; - padding-bottom: 2rem; - max-width: 37.5rem; - width: 100%; - } - - .buttons { - max-width: 37.5rem; - margin-top: auto; - display: flex; - align-items: center; - justify-content: space-around; - width: 100%; - position: relative; - height: 8.75rem; - - button { - cursor: var(--custom-cursor, pointer); - } - - &::before { - position: absolute; - content: ""; - width: 100%; - height: 2rem; - background: linear-gradient(0deg, #181f27, rgba(24, 31, 39, 0)); - z-index: 0; - top: -2rem; - } - - .button-wrapper { - width: 4rem; - display: flex; - flex-direction: column; - align-items: center; - - .button-text { - white-space: nowrap; - font-size: 0.75rem; - margin-top: 0.5rem; - color: #fff; - } - - &.microphone-wrapper { - width: 6rem; - - .button-text { - margin-top: 0.75rem; - font-size: 1rem; - } - } - } - - .Loading { - position: absolute; - transform: translate(0, -1.125rem); - - .Spinner { - --spinner-size: 6.5rem; - } - } - - .video-buttons { - display: flex; - flex-direction: column; - align-items: center; - } - - .small-button, - .smaller-button { - outline: none; - border: 0; - background: #15415b; - border-radius: 50%; - width: 3rem; - height: 3rem; - color: #fff; - font-size: 1.375rem; - display: flex; - align-items: center; - justify-content: center; - transition: 0.25s ease-out background-color; - - &:hover { - background: #11364b; - } - } - - .small-button.camera.active { - background: #15415b; - - &:hover { - background: #11364b; - } - } - - .small-button.speaker { - background: #2b3a51; - - &.active { - background: #496092; - } - } - - .small-button.leave { - background: #5a2824; - - &:hover { - background: #49201d; - } - } - - .smaller-button { - width: 2.5rem; - height: 2.5rem; - margin-bottom: 0.5rem; - padding: 0; - } - } - - &.landscape .scrollable { - display: flex; - flex-direction: row; - flex-grow: 1; - gap: 1rem; - align-items: flex-start; - max-width: 100%; - max-height: 100%; - } - - &.landscape .GroupCallParticipantVideo { - max-height: initial; - - video { - height: 100%; - } - } - - &.landscape .buttons { - position: absolute; - left: calc(50% - 15.625rem / 2); - transform: translateX(-50%); - width: auto; - gap: 1rem; - bottom: 4rem; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(10px); - border-radius: 1rem; - z-index: 5; - padding: 0.75rem 1rem; - height: auto; - - .button-text { - display: none; - } - - .video-buttons { - flex-direction: row; - gap: 1rem; - - .smaller-button { - margin-bottom: 0; - } - } - - .Loading { - transform: none; - .Spinner { - --spinner-size: 3.25rem; - } - } - - .MicrophoneButton { - canvas { - width: 2rem !important; - height: 2rem !important; - } - } - - .MicrophoneButton, - .microphone-wrapper { - width: 3rem; - height: 3rem; - - .AnimatedSticker { - display: flex; - align-items: center; - justify-content: center; - } - } - - &::before { - display: none; - } - } - - &.landscape.no-sidebar .buttons { - left: calc(50%); - } - - &.landscape .streams { - width: 100%; - height: 100%; - } - - &.landscape .videos { - width: 100%; - height: 100%; - - display: grid; - --column-count: 1; - grid-template-columns: repeat(var(--column-count), 1fr); - grid-auto-rows: 1fr; - - .GroupCallParticipantVideo { - max-height: 100%; - width: 100%; - - .thumbnail-wrapper { - height: 100%; - } - } - - &.span-last-video .GroupCallParticipantVideo:last-child { - grid-column: span var(--column-count); - } - } - - &.landscape .participants { - width: 15.625rem; - margin-top: 0; - } -} diff --git a/src/components/calls/group/GroupCall.tsx b/src/components/calls/group/GroupCall.tsx index 71abc5d43..a05e210fa 100644 --- a/src/components/calls/group/GroupCall.tsx +++ b/src/components/calls/group/GroupCall.tsx @@ -1,51 +1,43 @@ -import type { - GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant, -} from '../../../lib/secret-sauce'; -import { - IS_SCREENSHARE_SUPPORTED, switchCameraInput, toggleSpeaker, -} from '../../../lib/secret-sauce'; -import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useMemo, useRef, useState, + memo, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import '../../../global/actions/calls'; -import type { IAnchorPosition } from '../../../types'; +import type { + GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant, +} from '../../../lib/secret-sauce'; +import type { FC } from '../../../lib/teact/teact'; +import type { VideoParticipant } from './hooks/useGroupCallVideoLayout'; -import { - IS_ANDROID, - IS_IOS, - IS_REQUEST_FULLSCREEN_SUPPORTED, -} from '../../../util/windowEnvironment'; -import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; +import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce'; import buildClassName from '../../../util/buildClassName'; import { + selectCanInviteToActiveGroupCall, selectGroupCall, selectGroupCallParticipant, selectIsAdminInActiveGroupCall, } from '../../../global/selectors/calls'; -import { selectTabState } from '../../../global/selectors'; +import { selectChat, selectTabState } from '../../../global/selectors'; +import { compact } from '../../../util/iteratees'; import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; import useAppLayout from '../../../hooks/useAppLayout'; +import useGroupCallVideoLayout from './hooks/useGroupCallVideoLayout'; +import { useIntersectionObserver, useIsIntersecting } from '../../../hooks/useIntersectionObserver'; +import useLastCallback from '../../../hooks/useLastCallback'; -import Loading from '../../ui/Loading'; import Button from '../../ui/Button'; -import DropdownMenu from '../../ui/DropdownMenu'; -import MenuItem from '../../ui/MenuItem'; import Modal from '../../ui/Modal'; import MicrophoneButton from './MicrophoneButton'; -import AnimatedIcon from '../../common/AnimatedIcon'; import Checkbox from '../../ui/Checkbox'; -import GroupCallParticipantMenu from './GroupCallParticipantMenu'; import GroupCallParticipantList from './GroupCallParticipantList'; -import GroupCallParticipantStreams from './GroupCallParticipantStreams'; +import FloatingActionButton from '../../ui/FloatingActionButton'; +import GroupCallParticipantVideo from './GroupCallParticipantVideo'; -import './GroupCall.scss'; +import styles from './GroupCall.module.scss'; -const CAMERA_FLIP_PLAY_SEGMENT: [number, number] = [0, 10]; -const PARTICIPANT_HEIGHT = 60; +const INTERSECTION_THROTTLE = 200; export type OwnProps = { groupCallId: string; @@ -57,21 +49,21 @@ type StateProps = { title?: string; meParticipant?: TypeGroupCallParticipant; participantsCount?: number; - isSpeakerEnabled?: boolean; isAdmin: boolean; participants: Record; + canInvite: boolean; }; const GroupCall: FC = ({ groupCallId, isCallPanelVisible, connectionState, - isSpeakerEnabled, + participantsCount, title, meParticipant, isAdmin, participants, - + canInvite, }) => { const { toggleGroupCallVideo, @@ -80,24 +72,60 @@ const GroupCall: FC = ({ toggleGroupCallPanel, connectToActiveGroupCall, playGroupCallSound, + createGroupCallInviteLink, } = getActions(); const lang = useLang(); // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); - const { isMobile, isLandscape } = useAppLayout(); + + // eslint-disable-next-line no-null/no-null + const primaryVideoContainerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const secondaryVideoContainerRef = useRef(null); + + // eslint-disable-next-line no-null/no-null + const panelScrollTriggerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const panelRef = useRef(null); const [isLeaving, setIsLeaving] = useState(false); + const isOpen = !isCallPanelVisible && !isLeaving; + + const { observe } = useIntersectionObserver({ + rootRef: panelRef, + throttleMs: INTERSECTION_THROTTLE, + isDisabled: !isOpen, + }); + + const hasScrolled = !useIsIntersecting(panelScrollTriggerRef, isOpen ? observe : undefined); + + const { isMobile, isLandscape } = useAppLayout(); + const [isFullscreen, openFullscreen, closeFullscreen] = useFlag(); const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true); - const hasVideoParticipants = Object.values(participants).some(({ video, presentation }) => video || presentation); - const isLandscapeLayout = isFullscreen && (!isMobile || isLandscape) && hasVideoParticipants; + const isLandscapeLayout = Boolean(isFullscreen && isLandscape); - const [participantMenu, setParticipantMenu] = useState<{ - participant: TypeGroupCallParticipant; - anchor: IAnchorPosition; - } | undefined>(); - const [isParticipantMenuOpen, openParticipantMenu, closeParticipantMenu] = useFlag(); + const firstPresentation = useMemo(() => { + return Object.values(participants).find(({ presentation }) => presentation); + }, [participants]); + const videoParticipants = useMemo(() => Object.values(participants) + .filter(({ video, presentation }) => video || presentation) + .flatMap(({ id, video, presentation }) => compact([ + video ? { + id, + type: 'video' as const, + } : undefined, + presentation ? { + id, + type: 'screen' as const, + } : undefined, + ])), + [participants]); + const hasVideoParticipants = videoParticipants.length > 0; + + const groupCallTitle = title || lang('VoipGroupVoiceChat'); + const membersString = lang('Participants', participantsCount, 'i'); const [isConfirmLeaveModalOpen, openConfirmLeaveModal, closeConfirmLeaveModal] = useFlag(); const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false); @@ -105,20 +133,10 @@ const GroupCall: FC = ({ const hasVideo = meParticipant?.hasVideoStream; const hasPresentation = meParticipant?.hasPresentationStream; + const hasAudioStream = meParticipant?.hasAudioStream; const isConnecting = connectionState !== 'connected'; const canSelfUnmute = meParticipant?.canSelfUnmute; - const shouldRaiseHand = !canSelfUnmute && meParticipant?.isMuted; - const handleOpenParticipantMenu = useCallback((anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => { - const rect = anchor.getBoundingClientRect(); - const container = containerRef.current!; - - setParticipantMenu({ - anchor: { x: rect.left, y: rect.top - container.offsetTop + PARTICIPANT_HEIGHT }, - participant, - }); - - openParticipantMenu(); - }, [openParticipantMenu]); + const canRequestToSpeak = !canSelfUnmute && !hasAudioStream; useEffect(() => { if (connectionState === 'connected') { @@ -126,263 +144,360 @@ const GroupCall: FC = ({ } else if (connectionState === 'reconnecting') { playGroupCallSound({ sound: 'connecting' }); } - }, [connectionState, playGroupCallSound]); + }, [connectionState]); - const handleCloseConfirmLeaveModal = useCallback(() => { + const handleCloseConfirmLeaveModal = useLastCallback(() => { closeConfirmLeaveModal(); setIsEndGroupCallModal(false); - }, [closeConfirmLeaveModal]); + }); - const MainButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { - return ({ onTrigger, isOpen }) => ( - - ); - }, [lang]); - - const handleToggleFullscreen = useCallback(() => { + const handleToggleFullscreen = useLastCallback(() => { if (!containerRef.current) return; - if (isFullscreen) { - document.exitFullscreen().then(closeFullscreen); - } else { - containerRef.current.requestFullscreen().then(openFullscreen); - } - }, [closeFullscreen, isFullscreen, openFullscreen]); - - const handleToggleSidebar = useCallback(() => { - if (isSidebarOpen) { - closeSidebar(); - } else { - openSidebar(); - } - }, [closeSidebar, isSidebarOpen, openSidebar]); - - const handleStreamsDoubleClick = useCallback(() => { - if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return; - - if (!isFullscreen) { - closeSidebar(); - handleToggleFullscreen(); - } else { - handleToggleFullscreen(); - } - }, [closeSidebar, handleToggleFullscreen, isFullscreen]); - - const toggleFullscreen = useCallback(() => { if (isFullscreen) { closeFullscreen(); } else { openFullscreen(); } - }, [closeFullscreen, isFullscreen, openFullscreen]); + }); - const handleClose = useCallback(() => { - toggleGroupCallPanel(); - if (isFullscreen) { - closeFullscreen(); - } - }, [closeFullscreen, isFullscreen, toggleGroupCallPanel]); - - useEffect(() => { - if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return undefined; - const container = containerRef.current; - if (!container) return undefined; - - container.addEventListener('fullscreenchange', toggleFullscreen); - - return () => { - container.removeEventListener('fullscreenchange', toggleFullscreen); - }; - }, [toggleFullscreen]); - - const handleClickVideoOrSpeaker = () => { - if (shouldRaiseHand) { - toggleSpeaker(); + const handleToggleSidebar = useLastCallback(() => { + if (isSidebarOpen) { + closeSidebar(); } else { - toggleGroupCallVideo(); + openSidebar(); } - }; + }); + + const handleInviteMember = useLastCallback(() => { + createGroupCallInviteLink(); + }); + + const handleClickVideo = useLastCallback(() => { + toggleGroupCallVideo(); + }); useEffect(() => { connectToActiveGroupCall(); }, [connectToActiveGroupCall, groupCallId]); - const endGroupCall = useCallback(() => { - setIsEndGroupCallModal(true); - setShouldEndGroupCall(true); - openConfirmLeaveModal(); - if (isFullscreen) { - handleToggleFullscreen(); - } - }, [handleToggleFullscreen, isFullscreen, openConfirmLeaveModal]); - - const handleLeaveGroupCall = useCallback(() => { + const handleLeaveGroupCall = useLastCallback(() => { if (isAdmin && !isConfirmLeaveModalOpen) { openConfirmLeaveModal(); - if (isFullscreen) { - handleToggleFullscreen(); - } return; } playGroupCallSound({ sound: 'leave' }); setIsLeaving(true); closeConfirmLeaveModal(); - }, [ - closeConfirmLeaveModal, handleToggleFullscreen, isAdmin, isConfirmLeaveModalOpen, isFullscreen, - openConfirmLeaveModal, playGroupCallSound, - ]); + }); - const handleCloseAnimationEnd = useCallback(() => { - if (isLeaving) { - leaveGroupCall({ - shouldDiscard: shouldEndGroupCall, - }); - } - }, [isLeaving, leaveGroupCall, shouldEndGroupCall]); + const handleCloseAnimationEnd = useLastCallback(() => { + if (!isLeaving) return; - const handleToggleGroupCallPresentation = useCallback(() => { + leaveGroupCall({ + shouldDiscard: shouldEndGroupCall, + }); + }); + + const handleToggleGroupCallPresentation = useLastCallback(() => { toggleGroupCallPresentation(); - }, [toggleGroupCallPresentation]); + }); + + const canPinVideo = videoParticipants.length > 1 && isLandscapeLayout; + const isLandscapeWithVideos = isLandscapeLayout && hasVideoParticipants; + const [pinnedVideo, setPinnedVideo] = useState(undefined); + const { + videoLayout, + panelOffset, + } = useGroupCallVideoLayout({ + primaryContainerRef: primaryVideoContainerRef, + secondaryContainerRef: secondaryVideoContainerRef, + videoParticipants, + isLandscapeLayout, + pinnedVideo, + }); + + const handleOpenFirstPresentation = useLastCallback(() => { + if (!firstPresentation) return; + + setPinnedVideo({ + id: firstPresentation.id, + type: 'screen', + }); + }); + + useEffect(handleOpenFirstPresentation, [handleOpenFirstPresentation, Boolean(firstPresentation)]); + + useEffect(() => { + if (!pinnedVideo) return; + if (!videoParticipants.some((l) => l.type === pinnedVideo.type && l.id === pinnedVideo.id)) { + setPinnedVideo(undefined); + } + }, [pinnedVideo, videoLayout, videoParticipants]); return ( -
-

{title || lang('VoipGroupVoiceChat')}

- {IS_REQUEST_FULLSCREEN_SUPPORTED && ( - - )} - {isLandscapeLayout && ( - - )} - {((IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand) || isAdmin) && ( - - {IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && ( - +
+ + +

+ {title || lang('VoipGroupVoiceChat')} +

+ + {isLandscapeWithVideos && !isSidebarOpen && ( + )} - {isAdmin && ( - + +
+
+ )} + +
+
+
+ +
+ {!isLandscapeWithVideos && ( + )} - - )} + + {isLandscapeWithVideos && ( + + )} + +
+

+ {isLandscapeWithVideos ? membersString : groupCallTitle} +

+ {!isLandscapeWithVideos && ( + + {membersString} + + )} +
+ + {!isLandscapeWithVideos && canInvite && ( + + )} +
+ +
+
+ {videoLayout.map((layout) => { + const participant = participants[layout.participantId]; + if (!layout.isRemounted || !participant) { + return ( +
+ ); + } + + return ( + + ); + })} +
+ +
+
+ + + + +
+ + {videoLayout.map((layout) => { + const participant = participants[layout.participantId]; + if (layout.isRemounted || !participant) { + return ( +
+ ); + } + return ( + + ); + })} + +
-
-
- + - {(!isLandscapeLayout || isSidebarOpen) - && } -
+ - + -
- {isConnecting && } - -
-
- {hasVideo && (IS_ANDROID || IS_IOS) && ( - - )} - -
- -
- {lang(shouldRaiseHand ? 'VoipSpeaker' : 'VoipCamera')} -
-
- - - -
- - -
- {lang('VoipGroupLeave')} -
-
+
= ({ export default memo(withGlobal( (global, { groupCallId }): StateProps => { const { - connectionState, title, isSpeakerDisabled, participants, participantsCount, + connectionState, title, participants, participantsCount, chatId, } = selectGroupCall(global, groupCallId)! || {}; + const chat = chatId ? selectChat(global, chatId) : undefined; + return { connectionState, - title, - isSpeakerEnabled: !isSpeakerDisabled, + title: title || chat?.title, participantsCount, meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!), isCallPanelVisible: Boolean(selectTabState(global).isCallPanelVisible), isAdmin: selectIsAdminInActiveGroupCall(global), participants, + canInvite: selectCanInviteToActiveGroupCall(global), }; }, )(GroupCall)); diff --git a/src/components/calls/group/GroupCallParticipant.module.scss b/src/components/calls/group/GroupCallParticipant.module.scss new file mode 100644 index 000000000..2945ba2ff --- /dev/null +++ b/src/components/calls/group/GroupCallParticipant.module.scss @@ -0,0 +1,55 @@ +.root { + :global { + .ListItem-button { + --color-chat-hover: rgba(255, 255, 255, 0.04); + padding: 0.5rem; + } + + .multiline-item { + align-self: center; + } + + .title { + display: flex !important; + + .fullName { + font-weight: 500; + font-size: 1rem; + } + } + } +} + +.subtitle { + display: flex !important; + align-items: center; + gap: 0.375rem; + font-size: 1rem !important; +} + +.subtitleText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.icon { + margin-left: auto; + margin-right: 0.25rem; +} + +.subtitleBlue { + --color-text-secondary: #4da6e0; +} + +.subtitleRed { + --color-text-secondary: #ff706f; +} + +.subtitleGreen { + --color-text-secondary: #57bc6c; +} + +.avatar { + margin-right: 0.5rem; +} diff --git a/src/components/calls/group/GroupCallParticipant.scss b/src/components/calls/group/GroupCallParticipant.scss deleted file mode 100644 index 318aa0df5..000000000 --- a/src/components/calls/group/GroupCallParticipant.scss +++ /dev/null @@ -1,78 +0,0 @@ -.GroupCallParticipant { - position: relative; - display: flex; - flex-direction: row; - align-items: center; - color: #fff; - padding: 0.5rem 0.75rem; - border-radius: 0.75rem; - transition: 0.15s ease-out background-color; - cursor: var(--custom-cursor, pointer); - - &:hover { - background: #2f363e; - } - - audio { - display: none; - } - - .Avatar { - margin-right: 1rem; - } - - .info { - min-width: 0; - display: flex; - flex-direction: column; - - .name { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - - .about { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - color: #848d94; - font-size: 0.75rem; - - &.blue { - color: #4da6e0; - } - - &.green { - color: #57bc6c; - } - - &.red { - color: #ff706f; - } - } - } - - .microphone { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 2.75rem; - height: 2.75rem; - margin-left: auto; - font-size: 1.5rem; - color: #ff706f; - } - - &.can-self-unmute { - .microphone { - color: #848d94; - } - } - - .streams { - cursor: var(--custom-cursor, pointer); - display: flex; - } -} diff --git a/src/components/calls/group/GroupCallParticipant.tsx b/src/components/calls/group/GroupCallParticipant.tsx index d1b10c89c..da36db660 100644 --- a/src/components/calls/group/GroupCallParticipant.tsx +++ b/src/components/calls/group/GroupCallParticipant.tsx @@ -1,24 +1,31 @@ import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; import { THRESHOLD } from '../../../lib/secret-sauce'; import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useMemo, useRef } from '../../../lib/teact/teact'; +import React, { + memo, useCallback, useMemo, useRef, +} from '../../../lib/teact/teact'; import { withGlobal } from '../../../global'; import type { ApiChat, ApiUser } from '../../../api/types'; +import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; import buildClassName from '../../../util/buildClassName'; +import renderText from '../../common/helpers/renderText'; import { selectChat, selectUser } from '../../../global/selectors'; import useLang from '../../../hooks/useLang'; -import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; +import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; +import useMenuPosition from '../../../hooks/useMenuPosition'; import Avatar from '../../common/Avatar'; import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon'; +import ListItem from '../../ui/ListItem'; +import GroupCallParticipantMenu from './GroupCallParticipantMenu'; +import FullNameTitle from '../../common/FullNameTitle'; -import './GroupCallParticipant.scss'; +import styles from './GroupCallParticipant.module.scss'; type OwnProps = { participant: TypeGroupCallParticipant; - openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void; }; type StateProps = { @@ -27,67 +34,132 @@ type StateProps = { }; const GroupCallParticipant: FC = ({ - openParticipantMenu, participant, user, chat, }) => { // eslint-disable-next-line no-null/no-null - const anchorRef = useRef(null); + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const menuRef = useRef(null); const lang = useLang(); - const { isSelf, isMutedByMe, isMuted } = participant; + const { + isSelf, isMutedByMe, isMuted, hasVideoStream, hasPresentationStream, + } = participant; const isSpeaking = (participant.amplitude || 0) > THRESHOLD; const isRaiseHand = Boolean(participant.raiseHandRating); - const handleOnClick = () => { - if (isSelf) return; - openParticipantMenu(anchorRef.current!, participant); - }; + const { + isContextMenuOpen, + contextMenuPosition, + handleContextMenu, + handleBeforeContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, isSelf); + + const getTriggerElement = useCallback(() => ref.current, []); + + const getRootElement = useCallback( + () => ref.current!.closest('.custom-scroll, .no-scrollbar'), + [], + ); + + const getMenuElement = useCallback( + () => menuRef.current!, + [], + ); + + const getLayout = useCallback( + () => ({ withPortal: true }), + [], + ); + + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + getLayout, + ); + + const hasCustomVolume = Boolean( + !isMuted && isSpeaking && participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME, + ); const [aboutText, aboutColor] = useMemo(() => { - if (isSelf) { - return [lang('ThisIsYou'), 'blue']; - } if (isMutedByMe) { - return [lang('VoipGroupMutedForMe'), 'red']; + return [lang('VoipGroupMutedForMe'), styles.subtitleRed]; } - return isRaiseHand - ? [lang('WantsToSpeak'), 'blue'] - : (!isMuted && isSpeaking ? [ - participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME - ? lang('SpeakingWithVolume', - (participant.volume / GROUP_CALL_VOLUME_MULTIPLIER).toString()) - .replace('%%', '%') : lang('Speaking'), - 'green', - ] - : (participant.about ? [participant.about, ''] : [lang('Listening'), 'blue'])); - }, [isSpeaking, participant.volume, lang, isSelf, isMutedByMe, isRaiseHand, isMuted, participant.about]); + + if (isRaiseHand) { + return [lang('WantsToSpeak'), styles.subtitleBlue]; + } + + if (hasCustomVolume) { + return [ + lang('SpeakingWithVolume', + (participant.volume! / GROUP_CALL_VOLUME_MULTIPLIER).toString()) + .replace('%%', '%'), + styles.subtitleGreen, + ]; + } + + if (!isMuted && isSpeaking) { + return [ + lang('Speaking'), + styles.subtitleGreen, + ]; + } + + if (isSelf) { + return [lang('ThisIsYou'), styles.subtitleBlue]; + } + + return participant.about ? [participant.about, ''] : [lang('Listening'), styles.subtitleBlue]; + }, [ + isMutedByMe, isRaiseHand, isSelf, hasCustomVolume, isMuted, isSpeaking, participant.about, participant.volume, lang, + ]); if (!user && !chat) { return undefined; } - const name = user ? `${user.firstName || ''} ${user.lastName || ''}` : chat?.title; - return ( -
} + rightElement={} + className={styles.root} + onClick={handleContextMenu} + onMouseDown={handleBeforeContextMenu} + onContextMenu={handleContextMenu} + multiline + ripple + ref={ref} > - -
- {name} - {aboutText} -
-
- -
-
+ + + {hasPresentationStream && } + {hasVideoStream && } + {hasCustomVolume && } + {renderText(aboutText)} + + + ); }; diff --git a/src/components/calls/group/GroupCallParticipantList.module.scss b/src/components/calls/group/GroupCallParticipantList.module.scss new file mode 100644 index 000000000..6c9b10ede --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantList.module.scss @@ -0,0 +1,10 @@ +.root { + position: absolute; + width: 100%; + top: 0.25rem; + padding-bottom: 5rem; +} + +.portrait { + padding-bottom: 6rem; +} diff --git a/src/components/calls/group/GroupCallParticipantList.tsx b/src/components/calls/group/GroupCallParticipantList.tsx index 21681ae6d..fe2cc1b87 100644 --- a/src/components/calls/group/GroupCallParticipantList.tsx +++ b/src/components/calls/group/GroupCallParticipantList.tsx @@ -1,17 +1,22 @@ -import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; -import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact'; +import React, { memo, useMemo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import useLang from '../../../hooks/useLang'; +import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; +import type { FC } from '../../../lib/teact/teact'; + +import buildClassName from '../../../util/buildClassName'; import { selectActiveGroupCall } from '../../../global/selectors/calls'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; +import useLastCallback from '../../../hooks/useLastCallback'; import GroupCallParticipant from './GroupCallParticipant'; import InfiniteScroll from '../../ui/InfiniteScroll'; +import styles from './GroupCallParticipantList.module.scss'; + type OwnProps = { - openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void; + panelOffset: number; + isLandscape: boolean; }; type StateProps = { @@ -21,24 +26,22 @@ type StateProps = { }; const GroupCallParticipantList: FC = ({ + panelOffset, participants, participantsCount, - openParticipantMenu, + isLandscape, }) => { const { - createGroupCallInviteLink, loadMoreGroupCallParticipants, } = getActions(); - const lang = useLang(); - const participantsIds = useMemo(() => { return Object.keys(participants || {}); }, [participants]); - const handleLoadMoreGroupCallParticipants = useCallback(() => { + const handleLoadMoreGroupCallParticipants = useLastCallback(() => { loadMoreGroupCallParticipants(); - }, [loadMoreGroupCallParticipants]); + }); const [viewportIds, getMore] = useInfiniteScroll( handleLoadMoreGroupCallParticipants, @@ -46,37 +49,24 @@ const GroupCallParticipantList: FC = ({ participantsIds.length >= participantsCount, ); - function handleCreateGroupCallInviteLink() { - createGroupCallInviteLink(); - } - return ( -
-
-
- -
-
{lang('VoipGroupInviteMember')}
-
- - - {viewportIds?.map( - (participantId) => ( - participants![participantId] && ( - - ) - ), - )} - - -
+ + {participants && viewportIds?.map( + (participantId) => ( + participants[participantId] && ( + + ) + ), + )} + ); }; diff --git a/src/components/calls/group/GroupCallParticipantMenu.scss b/src/components/calls/group/GroupCallParticipantMenu.scss index e43265a9c..6a2c61630 100644 --- a/src/components/calls/group/GroupCallParticipantMenu.scss +++ b/src/components/calls/group/GroupCallParticipantMenu.scss @@ -1,12 +1,15 @@ @import '../../../styles/mixins'; .participant-menu { - position: absolute; --color-text: white; --color-background-compact-menu: #212121DD; --color-background-compact-menu-hover: #00000066; + position: absolute; + z-index: var(--z-modal-menu); + .bubble { + backdrop-filter: none !important; background: none !important; border-radius: 0; padding: 0; @@ -21,6 +24,7 @@ background: var(--color-background); border-radius: var(--border-radius-default); margin-bottom: 0.5rem; + backdrop-filter: blur(10px); } } @@ -44,7 +48,7 @@ padding: 0.75rem 1rem; .AnimatedSticker { - margin-right: 2rem; + margin-right: 1rem; } } @@ -86,7 +90,7 @@ position: absolute; left: -1.5rem; top: 0; - width: calc(100% + 3rem); + width: calc(100% + 1.5rem); margin: 0; z-index: 0; diff --git a/src/components/calls/group/GroupCallParticipantMenu.tsx b/src/components/calls/group/GroupCallParticipantMenu.tsx index 28666d751..a492da704 100644 --- a/src/components/calls/group/GroupCallParticipantMenu.tsx +++ b/src/components/calls/group/GroupCallParticipantMenu.tsx @@ -1,19 +1,17 @@ import type { GroupCallParticipant } from '../../../lib/secret-sauce'; import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useCallback, useEffect, useState, + memo, useEffect, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { IAnchorPosition } from '../../../types'; - import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import buildClassName from '../../../util/buildClassName'; -import buildStyle from '../../../util/buildStyle'; import useRunThrottled from '../../../hooks/useRunThrottled'; import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; import { selectIsAdminInActiveGroupCall } from '../../../global/selectors/calls'; import Menu from '../../ui/Menu'; @@ -28,9 +26,15 @@ const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34]; type OwnProps = { participant?: GroupCallParticipant; - closeDropdown: VoidFunction; + onCloseAnimationEnd: VoidFunction; + onClose: VoidFunction; isDropdownOpen: boolean; - anchor?: IAnchorPosition; + positionX?: 'left' | 'right'; + positionY?: 'top' | 'bottom'; + transformOriginX?: number; + transformOriginY?: number; + style?: string; + menuRef?: React.RefObject; }; type StateProps = { @@ -48,10 +52,16 @@ const SPEAKER_ICON_SIZE = 24; const GroupCallParticipantMenu: FC = ({ participant, - closeDropdown, + onCloseAnimationEnd, + onClose, isDropdownOpen, - anchor, isAdmin, + positionY, + menuRef, + positionX, + style, + transformOriginY, + transformOriginX, }) => { const { toggleGroupCallMute, @@ -75,6 +85,22 @@ const GroupCallParticipantMenu: FC = ({ isMutedByMe ? VOLUME_ZERO : ((participant?.volume || GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER), ); + const [shouldPlay, setShouldPlay] = useState(false); + + const isLocalVolumeZero = localVolume === VOLUME_ZERO; + const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT; + + useEffect(() => { + if (isDropdownOpen) return; + setShouldPlay(false); + }, [isDropdownOpen]); + + const handleSetLocalVolume = useLastCallback((volume: number) => { + setLocalVolume(volume); + const isNewLocalVolumeZero = volume === VOLUME_ZERO; + setShouldPlay(isNewLocalVolumeZero !== isLocalVolumeZero); + }); + useEffect(() => { setLocalVolume(isMutedByMe ? VOLUME_ZERO @@ -85,49 +111,49 @@ const GroupCallParticipantMenu: FC = ({ const runThrottled = useRunThrottled(VOLUME_CHANGE_THROTTLE); - const handleRemove = useCallback((e: React.SyntheticEvent) => { + const handleRemove = useLastCallback((e: React.SyntheticEvent) => { e.stopPropagation(); openDeleteUserModal(); - closeDropdown(); - }, [openDeleteUserModal, closeDropdown]); + onClose(); + }); - const handleCancelRequestToSpeak = useCallback((e: React.SyntheticEvent) => { + const handleCancelRequestToSpeak = useLastCallback((e: React.SyntheticEvent) => { e.stopPropagation(); requestToSpeak({ value: false, }); - closeDropdown(); - }, [requestToSpeak, closeDropdown]); + onClose(); + }); - const handleMute = useCallback((e: React.SyntheticEvent) => { + const handleMute = useLastCallback((e: React.SyntheticEvent) => { e.stopPropagation(); - closeDropdown(); + onClose(); if (!isAdmin) { - setLocalVolume(isMutedByMe ? GROUP_CALL_DEFAULT_VOLUME / GROUP_CALL_VOLUME_MULTIPLIER : VOLUME_ZERO); + handleSetLocalVolume(isMutedByMe ? GROUP_CALL_DEFAULT_VOLUME / GROUP_CALL_VOLUME_MULTIPLIER : VOLUME_ZERO); + } else if (shouldRaiseHand) { + handleSetLocalVolume((participant?.volume ?? GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER); } toggleGroupCallMute({ participantId: id!, value: isAdmin ? !shouldRaiseHand : !isMutedByMe, }); - }, [closeDropdown, toggleGroupCallMute, id, isAdmin, shouldRaiseHand, isMutedByMe]); + }); - const handleOpenProfile = useCallback((e: React.SyntheticEvent) => { + const handleOpenProfile = useLastCallback((e: React.SyntheticEvent) => { e.stopPropagation(); toggleGroupCallPanel(); openChat({ id, }); - closeDropdown(); - }, [toggleGroupCallPanel, closeDropdown, openChat, id]); - - const isLocalVolumeZero = localVolume === VOLUME_ZERO; - const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT; + onClose(); + }); const handleChangeVolume = (e: React.ChangeEvent) => { const value = Number(e.target.value); - setLocalVolume(value); + handleSetLocalVolume(value); + runThrottled(() => { if (value === VOLUME_ZERO) { toggleGroupCallMute({ @@ -147,11 +173,16 @@ const GroupCallParticipantMenu: FC = ({
{!isSelf && !shouldRaiseHand && (
@@ -173,6 +204,7 @@ const GroupCallParticipantMenu: FC = ({
diff --git a/src/components/calls/group/GroupCallParticipantStreams.tsx b/src/components/calls/group/GroupCallParticipantStreams.tsx deleted file mode 100644 index f5f11f0af..000000000 --- a/src/components/calls/group/GroupCallParticipantStreams.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import type { GroupCallParticipant } from '../../../lib/secret-sauce'; -import type { FC } from '../../../lib/teact/teact'; -import React, { - memo, useCallback, useMemo, useState, -} from '../../../lib/teact/teact'; -import { withGlobal } from '../../../global'; -import GroupCallParticipantVideo from './GroupCallParticipantVideo'; -import { selectActiveGroupCall } from '../../../global/selectors/calls'; -import buildClassName from '../../../util/buildClassName'; - -type OwnProps = { - onDoubleClick?: VoidFunction; -}; - -type StateProps = { - participants?: Record; -}; - -type SelectedVideo = { - type: 'video' | 'presentation'; - id: string; -}; - -const GroupCallParticipantStreams: FC = ({ - participants, - onDoubleClick, -}) => { - const [selectedVideo, setSelectedVideo] = useState(undefined); - const presentationParticipants = useMemo(() => { - return Object.values(participants || {}).filter((participant) => participant.hasPresentationStream); - }, [participants]); - const videoParticipants = useMemo(() => { - return Object.values(participants || {}).filter((participant) => participant.hasVideoStream); - }, [participants]); - - const totalVideoCount = videoParticipants.length + presentationParticipants.length; - // TODO replace with more adequate solution. - // There's a max of 30 videos or so right now - const columnCount = totalVideoCount <= 2 ? 1 : ( - totalVideoCount <= 6 ? 2 : ( - totalVideoCount <= 9 ? 3 : 4 - ) - ); - - const shouldSpanLastVideo = totalVideoCount === 3 || (columnCount === 2 && totalVideoCount % 2 !== 0); - - const handleClickVideo = useCallback((id: string, type: 'video' | 'presentation') => { - if (!selectedVideo || (id !== selectedVideo.id || type !== selectedVideo.type)) { - setSelectedVideo({ - id, - type, - }); - } else { - setSelectedVideo(undefined); - } - }, [selectedVideo]); - - return ( -
-
- {selectedVideo && ( - - )} - - {!selectedVideo ? presentationParticipants.map((participant) => ( - - )) : undefined} - {!selectedVideo ? videoParticipants.map((participant) => ( - - )) : undefined} -
-
- ); -}; - -export default memo(withGlobal( - (global): StateProps => { - const { participants } = selectActiveGroupCall(global) || {}; - return { - participants, - }; - }, -)(GroupCallParticipantStreams)); diff --git a/src/components/calls/group/GroupCallParticipantVideo.module.scss b/src/components/calls/group/GroupCallParticipantVideo.module.scss new file mode 100644 index 000000000..173c63718 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantVideo.module.scss @@ -0,0 +1,150 @@ +.wrapper { + position: absolute; + opacity: 1; + transform: translate(var(--x), var(--y)) scale(1); + + width: var(--width); + height: var(--height); +} + +.hidden { + opacity: 0; + transform: translate(var(--x), var(--y)) scale(0.6); +} + +.noAnimate { + transition: none; +} + +.root { + position: relative; + width: 100%; + height: 100%; + + display: flex; + border-radius: 0.625rem; + user-select: none; + + &::before { + content: ''; + display: block; + position: absolute; + inset: -0.125rem; + border-radius: 0.75rem; + background: var(--gradient-speaking); + + transform: scale(0.96); + transition: 0.25s ease-in-out transform; + } + + &::after { + content: ''; + display: block; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 55.62%, rgba(0, 0, 0, 0.5) 86.46%); + z-index: 2; + border-radius: 0.625rem; + } + + &.speaking::before { + transform: scale(1); + } +} + +.video { + width: 100%; + display: block; + object-fit: contain; + border-radius: 0.625rem; + z-index: 2; +} + +.videoFallback { + composes: video; + position: absolute; + height: 100%; + z-index: 1; +} + +.thumbnailWrapper { + position: absolute; + z-index: 0; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 0.625rem; + background: #000; +} + +.thumbnail { + object-fit: cover; + width: 100%; + height: 100%; +} + +.flipped { + transform: rotateY(180deg); +} + +.pinButton { + position: absolute; + inset-inline-end: 0.25rem; + inset-block-start: 0.25rem; + z-index: 3; + color: #FFFFFF !important; +} + +.bottomPanel { + position: absolute; + inset-block-end: 0; + inset-inline: 0; + border-end-end-radius: 0.625rem; + border-end-start-radius: 0.625rem; + + padding: 0.5rem 0.75rem; + display: flex; + align-items: center; + gap: 0.25rem; + z-index: 3; +} + +.info { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 1rem; + min-width: 0; +} + +.pinned .bottomPanel, .pinned::after { + opacity: 0; + transition: 0.25s ease-in-out opacity; +} + +.pinned:hover .bottomPanel, .pinned:hover::after { + opacity: 1; +} + +.name { + color: #FFFFFF; + font-weight: 500; + line-height: 1.125rem; + + :global(.fullName) { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + font-size: 1rem; + } +} + +.status { + color: #FFFFFF; + opacity: 0.6; + line-height: 1.125rem; +} + +.icon { + margin-left: auto; +} diff --git a/src/components/calls/group/GroupCallParticipantVideo.scss b/src/components/calls/group/GroupCallParticipantVideo.scss deleted file mode 100644 index 2918592e5..000000000 --- a/src/components/calls/group/GroupCallParticipantVideo.scss +++ /dev/null @@ -1,126 +0,0 @@ -.GroupCallParticipantVideo { - border-radius: 0.75rem; - overflow: hidden; - position: relative; - max-height: 12.875rem; - width: calc(50% - 0.25rem); - /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ - transition: 0.25s ease-out width; - cursor: var(--custom-cursor, pointer); - - .thumbnail-avatar { - position: absolute; - border-radius: 0; - width: 100%; - height: 100%; - transform: scale(1.1); - - img { - filter: blur(10px); - border-radius: 0; - object-fit: cover; - } - } - - &:last-child:nth-child(odd) { - width: 100%; - } - - &::before { - box-shadow: 0 0 0 3px transparent inset; - width: 100%; - height: 100%; - position: absolute; - display: block; - content: ""; - z-index: 5; - border-radius: 0.75rem; - transition: 0.25s ease-out box-shadow; - } - - &.active::before { - box-shadow: 0px 0px 0px 3px #78ee7e inset; - } - - .back-button { - position: absolute; - z-index: 5; - top: 0.75rem; - left: 0.75rem; - background: rgba(0, 0, 0, 0.3); - border: 0; - color: white; - border-radius: 1rem; - padding: 0.25rem 0.75rem; - display: flex; - align-items: center; - gap: 0.25rem; - transition: 0.25s ease-out opacity, 0.25s ease-out background-color; - opacity: 0; - cursor: var(--custom-cursor, pointer); - outline: none !important; - - &:hover { - background: rgba(0, 0, 0, 0.4); - } - } - - video { - display: block; - width: 100%; - } - - .video { - object-fit: contain; - height: 12.5rem; - position: relative; - } - - .thumbnail-wrapper { - position: absolute; - top: 50%; - left: 50%; - z-index: 0; - width: 100%; - transform: translate(-50%, -50%) scale(1.5); - background: black; - } - - .thumbnail { - filter: blur(10px) brightness(0.5); - object-fit: cover; - } - - .info { - position: absolute; - bottom: 0; - color: #fff; - display: flex; - align-items: center; - padding: 0 0.5rem 0.25rem; - width: 100%; - height: 2rem; - background: linear-gradient(0deg, #000, transparent); - transition: 0.25s ease-out opacity; - opacity: 0; - - .name { - margin-left: 0.5rem; - } - - .last-icon { - margin-left: auto; - } - } -} - -.videos:hover .GroupCallParticipantVideo { - - .info { - opacity: 1; - } - - .back-button { - opacity: 1; - } -} diff --git a/src/components/calls/group/GroupCallParticipantVideo.tsx b/src/components/calls/group/GroupCallParticipantVideo.tsx index e09f3ef36..46857ee95 100644 --- a/src/components/calls/group/GroupCallParticipantVideo.tsx +++ b/src/components/calls/group/GroupCallParticipantVideo.tsx @@ -1,77 +1,314 @@ -import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; -import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce'; -import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useCallback } from '../../../lib/teact/teact'; +import React, { + memo, useCallback, useEffect, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; import { withGlobal } from '../../../global'; +import type { FC } from '../../../lib/teact/teact'; +import type { VideoLayout, VideoParticipant } from './hooks/useGroupCallVideoLayout'; +import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce'; import type { ApiChat, ApiUser } from '../../../api/types'; -import { GROUP_CALL_THUMB_VIDEO_DISABLED } from '../../../config'; +import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; +import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce'; import buildClassName from '../../../util/buildClassName'; import { selectChat, selectUser } from '../../../global/selectors'; +import { animate } from '../../../util/animation'; +import { fastRaf } from '../../../util/schedulers'; +import { requestMutation } from '../../../lib/fasterdom/fasterdom'; + import useLang from '../../../hooks/useLang'; +import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; +import useMenuPosition from '../../../hooks/useMenuPosition'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useInterval from '../../../hooks/useInterval'; -import Avatar from '../../common/Avatar'; +import Button from '../../ui/Button'; +import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon'; +import FullNameTitle from '../../common/FullNameTitle'; +import GroupCallParticipantMenu from './GroupCallParticipantMenu'; -import './GroupCallParticipantVideo.scss'; +import styles from './GroupCallParticipantVideo.module.scss'; + +const VIDEO_FALLBACK_UPDATE_INTERVAL = 1000; type OwnProps = { + layout: VideoLayout; + setPinned: (participant?: VideoParticipant) => void; + pinnedVideo: VideoParticipant | undefined; + canPin: boolean; participant: TypeGroupCallParticipant; - type: 'video' | 'presentation'; - onClick?: (id: string, type: 'video' | 'presentation') => void; - isFullscreen?: boolean; + className?: string; }; type StateProps = { user?: ApiUser; chat?: ApiChat; - currentUserId?: string; - isActive?: boolean; }; const GroupCallParticipantVideo: FC = ({ - type, - onClick, + layout, + pinnedVideo, + setPinned, + canPin, + className, + participant, user, chat, - isActive, - isFullscreen, }) => { const lang = useLang(); - const handleClick = useCallback(() => { - if (onClick) { - onClick(user?.id || chat!.id, type); - } - }, [chat, onClick, type, user?.id]); + // eslint-disable-next-line no-null/no-null + const thumbnailRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const videoRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const videoFallbackRef = useRef(null); - if (!user && !chat) return undefined; + const { + x, y, width, height, noAnimate, isRemoved, + type, + } = layout; + const { + isSelf, isMutedByMe, isMuted, + } = participant; + const isPinned = pinnedVideo?.id === participant.id && pinnedVideo?.type === type; + const isSpeaking = (participant.amplitude || 0) > THRESHOLD; + const isRaiseHand = Boolean(participant.raiseHandRating); + const shouldFlipVideo = type === 'video' && participant.isSelf; + + const status = useMemo(() => { + if (isSelf) { + return lang('ThisIsYou'); + } + + if (isMutedByMe) { + return lang('VoipGroupMutedForMe'); + } + + if (isRaiseHand) { + return lang('WantsToSpeak'); + } + + if (isMuted || !isSpeaking) { + return lang('Listening'); + } + + if (participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME) { + return lang('SpeakingWithVolume', + (participant.volume / GROUP_CALL_VOLUME_MULTIPLIER).toString()) + .replace('%%', '%'); + } + + return lang('Speaking'); + }, [isSpeaking, participant.volume, lang, isSelf, isMutedByMe, isRaiseHand, isMuted]); + + const prevLayoutRef = useRef(); + if (!isRemoved) { + prevLayoutRef.current = layout; + } + const { + x: prevX, y: prevY, width: prevWidth, height: prevHeight, + } = prevLayoutRef.current || {}; + + const [currentX, currentY, currentWidth, currentHeight] = isRemoved + ? [prevX, prevY, prevWidth, prevHeight] : [x, y, width, height]; + + const [isHidden, setIsHidden] = useState(!noAnimate); const streams = getUserStreams(user?.id || chat!.id); + const actualStream = type === 'video' ? streams?.video : streams?.presentation; + const streamRef = useRef(actualStream); + if (actualStream?.active && actualStream?.getVideoTracks()[0].enabled) { + streamRef.current = actualStream; + } + const stream = streamRef.current; + + const handleInactive = useLastCallback(() => { + const video = videoRef.current; + if (!video) return; + // eslint-disable-next-line no-null/no-null + video.srcObject = null; + }); + + useEffect(() => { + stream?.addEventListener('inactive', handleInactive); + return () => { + stream?.removeEventListener('inactive', handleInactive); + }; + }, [handleInactive, stream]); + + useEffect(() => { + setIsHidden(false); + }, []); + + // When video stream is removed, the video element starts showing empty black screen. + // To avoid that, we hide the video element and show the fallback frame instead, which is constantly updated + // every VIDEO_FALLBACK_UPDATE_INTERVAL milliseconds. + useInterval(() => { + if (!stream?.active) return; + const video = videoRef.current!; + const canvas = videoFallbackRef.current!; + + requestMutation(() => { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d')!.drawImage(video, 0, 0, canvas.width, canvas.height); + }); + }, VIDEO_FALLBACK_UPDATE_INTERVAL); + + useEffect(() => { + const video = videoRef.current; + const thumbnail = thumbnailRef.current; + if (!video || !thumbnail || !stream) return undefined; + + const ctx = thumbnail.getContext('2d', { alpha: false }); + if (!ctx) return undefined; + + let isDrawing = true; + requestMutation(() => { + if (!isDrawing) return; + thumbnail.width = 16; + thumbnail.height = 16; + ctx.filter = 'blur(2px)'; + + const draw = () => { + if (!isDrawing) return false; + if (!stream.active) { + return false; + } + ctx.drawImage(video, 0, 0, video.videoWidth, video.videoHeight, 0, 0, thumbnail.width, thumbnail.height); + return true; + }; + + animate(draw, fastRaf); + }); + + return () => { + isDrawing = false; + }; + }, [stream]); + + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const menuRef = useRef(null); + + const { + isContextMenuOpen, + contextMenuPosition, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, isSelf); + + const getTriggerElement = useCallback(() => ref.current, []); + + const getRootElement = useCallback( + () => ref.current!.closest('.custom-scroll, .no-scrollbar'), + [], + ); + + const getMenuElement = useCallback( + () => menuRef.current!, + [], + ); + + const getLayout = useCallback( + () => ({ withPortal: true }), + [], + ); + + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + getLayout, + ); + + const handleClickPin = useCallback(() => { + setPinned(!isPinned ? { + id: user?.id || chat!.id, + type, + } : undefined); + }, [chat, isPinned, setPinned, type, user?.id]); return (
- {isFullscreen && ( - - )} - - {!GROUP_CALL_THUMB_VIDEO_DISABLED && ( -
-