diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 023beb669..607e7229f 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 b41c21756..183b1567e 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 deleted file mode 100644 index 1857dfa94..000000000 --- a/src/components/calls/group/GroupCall.module.scss +++ /dev/null @@ -1,296 +0,0 @@ -.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 new file mode 100644 index 000000000..f2735ffd3 --- /dev/null +++ b/src/components/calls/group/GroupCall.scss @@ -0,0 +1,345 @@ +.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 a05e210fa..71abc5d43 100644 --- a/src/components/calls/group/GroupCall.tsx +++ b/src/components/calls/group/GroupCall.tsx @@ -1,43 +1,51 @@ +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, useEffect, useMemo, useRef, useState, + memo, useCallback, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import '../../../global/actions/calls'; -import type { - GroupCallConnectionState, GroupCallParticipant as TypeGroupCallParticipant, -} from '../../../lib/secret-sauce'; -import type { FC } from '../../../lib/teact/teact'; -import type { VideoParticipant } from './hooks/useGroupCallVideoLayout'; +import type { IAnchorPosition } from '../../../types'; -import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce'; +import { + IS_ANDROID, + IS_IOS, + IS_REQUEST_FULLSCREEN_SUPPORTED, +} from '../../../util/windowEnvironment'; +import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import buildClassName from '../../../util/buildClassName'; import { - selectCanInviteToActiveGroupCall, selectGroupCall, selectGroupCallParticipant, selectIsAdminInActiveGroupCall, } from '../../../global/selectors/calls'; -import { selectChat, selectTabState } from '../../../global/selectors'; -import { compact } from '../../../util/iteratees'; +import { selectTabState } from '../../../global/selectors'; 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 FloatingActionButton from '../../ui/FloatingActionButton'; -import GroupCallParticipantVideo from './GroupCallParticipantVideo'; +import GroupCallParticipantStreams from './GroupCallParticipantStreams'; -import styles from './GroupCall.module.scss'; +import './GroupCall.scss'; -const INTERSECTION_THROTTLE = 200; +const CAMERA_FLIP_PLAY_SEGMENT: [number, number] = [0, 10]; +const PARTICIPANT_HEIGHT = 60; export type OwnProps = { groupCallId: string; @@ -49,21 +57,21 @@ type StateProps = { title?: string; meParticipant?: TypeGroupCallParticipant; participantsCount?: number; + isSpeakerEnabled?: boolean; isAdmin: boolean; participants: Record; - canInvite: boolean; }; const GroupCall: FC = ({ groupCallId, isCallPanelVisible, connectionState, - participantsCount, + isSpeakerEnabled, title, meParticipant, isAdmin, participants, - canInvite, + }) => { const { toggleGroupCallVideo, @@ -72,60 +80,24 @@ const GroupCall: FC = ({ toggleGroupCallPanel, connectToActiveGroupCall, playGroupCallSound, - createGroupCallInviteLink, } = getActions(); const lang = useLang(); // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); - - // 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 [isLeaving, setIsLeaving] = useState(false); const [isFullscreen, openFullscreen, closeFullscreen] = useFlag(); const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true); - const isLandscapeLayout = Boolean(isFullscreen && isLandscape); + const hasVideoParticipants = Object.values(participants).some(({ video, presentation }) => video || presentation); + const isLandscapeLayout = isFullscreen && (!isMobile || isLandscape) && hasVideoParticipants; - 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 [participantMenu, setParticipantMenu] = useState<{ + participant: TypeGroupCallParticipant; + anchor: IAnchorPosition; + } | undefined>(); + const [isParticipantMenuOpen, openParticipantMenu, closeParticipantMenu] = useFlag(); const [isConfirmLeaveModalOpen, openConfirmLeaveModal, closeConfirmLeaveModal] = useFlag(); const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false); @@ -133,10 +105,20 @@ const GroupCall: FC = ({ const hasVideo = meParticipant?.hasVideoStream; const hasPresentation = meParticipant?.hasPresentationStream; - const hasAudioStream = meParticipant?.hasAudioStream; const isConnecting = connectionState !== 'connected'; const canSelfUnmute = meParticipant?.canSelfUnmute; - const canRequestToSpeak = !canSelfUnmute && !hasAudioStream; + 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]); useEffect(() => { if (connectionState === 'connected') { @@ -144,360 +126,263 @@ const GroupCall: FC = ({ } else if (connectionState === 'reconnecting') { playGroupCallSound({ sound: 'connecting' }); } - }, [connectionState]); + }, [connectionState, playGroupCallSound]); - const handleCloseConfirmLeaveModal = useLastCallback(() => { + const handleCloseConfirmLeaveModal = useCallback(() => { closeConfirmLeaveModal(); setIsEndGroupCallModal(false); - }); + }, [closeConfirmLeaveModal]); - const handleToggleFullscreen = useLastCallback(() => { + const MainButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen }) => ( + + ); + }, [lang]); + + const handleToggleFullscreen = useCallback(() => { if (!containerRef.current) return; if (isFullscreen) { - closeFullscreen(); + document.exitFullscreen().then(closeFullscreen); } else { - openFullscreen(); + containerRef.current.requestFullscreen().then(openFullscreen); } - }); + }, [closeFullscreen, isFullscreen, openFullscreen]); - const handleToggleSidebar = useLastCallback(() => { + const handleToggleSidebar = useCallback(() => { if (isSidebarOpen) { closeSidebar(); } else { openSidebar(); } - }); + }, [closeSidebar, isSidebarOpen, openSidebar]); - const handleInviteMember = useLastCallback(() => { - createGroupCallInviteLink(); - }); + const handleStreamsDoubleClick = useCallback(() => { + if (!IS_REQUEST_FULLSCREEN_SUPPORTED) return; - const handleClickVideo = useLastCallback(() => { - toggleGroupCallVideo(); - }); + 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(); + } else { + toggleGroupCallVideo(); + } + }; useEffect(() => { connectToActiveGroupCall(); }, [connectToActiveGroupCall, groupCallId]); - const handleLeaveGroupCall = useLastCallback(() => { + const endGroupCall = useCallback(() => { + setIsEndGroupCallModal(true); + setShouldEndGroupCall(true); + openConfirmLeaveModal(); + if (isFullscreen) { + handleToggleFullscreen(); + } + }, [handleToggleFullscreen, isFullscreen, openConfirmLeaveModal]); + + const handleLeaveGroupCall = useCallback(() => { if (isAdmin && !isConfirmLeaveModalOpen) { openConfirmLeaveModal(); + if (isFullscreen) { + handleToggleFullscreen(); + } return; } playGroupCallSound({ sound: 'leave' }); setIsLeaving(true); closeConfirmLeaveModal(); - }); + }, [ + closeConfirmLeaveModal, handleToggleFullscreen, isAdmin, isConfirmLeaveModalOpen, isFullscreen, + openConfirmLeaveModal, playGroupCallSound, + ]); - const handleCloseAnimationEnd = useLastCallback(() => { - if (!isLeaving) return; - - leaveGroupCall({ - shouldDiscard: shouldEndGroupCall, - }); - }); - - const handleToggleGroupCallPresentation = useLastCallback(() => { - 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); + const handleCloseAnimationEnd = useCallback(() => { + if (isLeaving) { + leaveGroupCall({ + shouldDiscard: shouldEndGroupCall, + }); } - }, [pinnedVideo, videoLayout, videoParticipants]); + }, [isLeaving, leaveGroupCall, shouldEndGroupCall]); + + const handleToggleGroupCallPresentation = useCallback(() => { + toggleGroupCallPresentation(); + }, [toggleGroupCallPresentation]); return ( - {isLandscapeWithVideos && ( -
-
- - -

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

- - {isLandscapeWithVideos && !isSidebarOpen && ( - + )} + {isLandscapeLayout && ( + + )} + {((IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand) || isAdmin) && ( + + {IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && ( + - - + {lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')} + )} -
- -
-
- )} - -
-
-
- -
- {!isLandscapeWithVideos && ( - + {lang('VoipGroupLeaveAlertEndChat')} + )} - - {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, participants, participantsCount, chatId, + connectionState, title, isSpeakerDisabled, participants, participantsCount, } = selectGroupCall(global, groupCallId)! || {}; - const chat = chatId ? selectChat(global, chatId) : undefined; - return { connectionState, - title: title || chat?.title, + title, + isSpeakerEnabled: !isSpeakerDisabled, 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 deleted file mode 100644 index 2945ba2ff..000000000 --- a/src/components/calls/group/GroupCallParticipant.module.scss +++ /dev/null @@ -1,55 +0,0 @@ -.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 new file mode 100644 index 000000000..318aa0df5 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipant.scss @@ -0,0 +1,78 @@ +.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 da36db660..d1b10c89c 100644 --- a/src/components/calls/group/GroupCallParticipant.tsx +++ b/src/components/calls/group/GroupCallParticipant.tsx @@ -1,31 +1,24 @@ 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, useCallback, useMemo, useRef, -} from '../../../lib/teact/teact'; +import React, { memo, 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 useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; -import useMenuPosition from '../../../hooks/useMenuPosition'; +import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; import Avatar from '../../common/Avatar'; import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon'; -import ListItem from '../../ui/ListItem'; -import GroupCallParticipantMenu from './GroupCallParticipantMenu'; -import FullNameTitle from '../../common/FullNameTitle'; -import styles from './GroupCallParticipant.module.scss'; +import './GroupCallParticipant.scss'; type OwnProps = { participant: TypeGroupCallParticipant; + openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void; }; type StateProps = { @@ -34,132 +27,67 @@ type StateProps = { }; const GroupCallParticipant: FC = ({ + openParticipantMenu, participant, user, chat, }) => { // 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 anchorRef = useRef(null); const lang = useLang(); - const { - isSelf, isMutedByMe, isMuted, hasVideoStream, hasPresentationStream, - } = participant; + const { isSelf, isMutedByMe, isMuted } = participant; const isSpeaking = (participant.amplitude || 0) > THRESHOLD; const isRaiseHand = Boolean(participant.raiseHandRating); - 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 handleOnClick = () => { + if (isSelf) return; + openParticipantMenu(anchorRef.current!, participant); + }; const [aboutText, aboutColor] = useMemo(() => { - if (isMutedByMe) { - return [lang('VoipGroupMutedForMe'), styles.subtitleRed]; - } - - 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 [lang('ThisIsYou'), 'blue']; } - - return participant.about ? [participant.about, ''] : [lang('Listening'), styles.subtitleBlue]; - }, [ - isMutedByMe, isRaiseHand, isSelf, hasCustomVolume, isMuted, isSpeaking, participant.about, participant.volume, lang, - ]); + if (isMutedByMe) { + return [lang('VoipGroupMutedForMe'), 'red']; + } + 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 (!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} +
- - - {hasPresentationStream && } - {hasVideoStream && } - {hasCustomVolume && } - {renderText(aboutText)} - - - + +
+ {name} + {aboutText} +
+
+ +
+
); }; diff --git a/src/components/calls/group/GroupCallParticipantList.module.scss b/src/components/calls/group/GroupCallParticipantList.module.scss deleted file mode 100644 index 6c9b10ede..000000000 --- a/src/components/calls/group/GroupCallParticipantList.module.scss +++ /dev/null @@ -1,10 +0,0 @@ -.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 fe2cc1b87..21681ae6d 100644 --- a/src/components/calls/group/GroupCallParticipantList.tsx +++ b/src/components/calls/group/GroupCallParticipantList.tsx @@ -1,22 +1,17 @@ -import React, { memo, useMemo } from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; - 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 { getActions, withGlobal } from '../../../global'; -import buildClassName from '../../../util/buildClassName'; +import useLang from '../../../hooks/useLang'; 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 = { - panelOffset: number; - isLandscape: boolean; + openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void; }; type StateProps = { @@ -26,22 +21,24 @@ type StateProps = { }; const GroupCallParticipantList: FC = ({ - panelOffset, participants, participantsCount, - isLandscape, + openParticipantMenu, }) => { const { + createGroupCallInviteLink, loadMoreGroupCallParticipants, } = getActions(); + const lang = useLang(); + const participantsIds = useMemo(() => { return Object.keys(participants || {}); }, [participants]); - const handleLoadMoreGroupCallParticipants = useLastCallback(() => { + const handleLoadMoreGroupCallParticipants = useCallback(() => { loadMoreGroupCallParticipants(); - }); + }, [loadMoreGroupCallParticipants]); const [viewportIds, getMore] = useInfiniteScroll( handleLoadMoreGroupCallParticipants, @@ -49,24 +46,37 @@ const GroupCallParticipantList: FC = ({ participantsIds.length >= participantsCount, ); + function handleCreateGroupCallInviteLink() { + createGroupCallInviteLink(); + } + return ( - - {participants && viewportIds?.map( - (participantId) => ( - participants[participantId] && ( - - ) - ), - )} - +
+
+
+ +
+
{lang('VoipGroupInviteMember')}
+
+ + + {viewportIds?.map( + (participantId) => ( + participants![participantId] && ( + + ) + ), + )} + + +
); }; diff --git a/src/components/calls/group/GroupCallParticipantMenu.scss b/src/components/calls/group/GroupCallParticipantMenu.scss index 6a2c61630..e43265a9c 100644 --- a/src/components/calls/group/GroupCallParticipantMenu.scss +++ b/src/components/calls/group/GroupCallParticipantMenu.scss @@ -1,15 +1,12 @@ @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; @@ -24,7 +21,6 @@ background: var(--color-background); border-radius: var(--border-radius-default); margin-bottom: 0.5rem; - backdrop-filter: blur(10px); } } @@ -48,7 +44,7 @@ padding: 0.75rem 1rem; .AnimatedSticker { - margin-right: 1rem; + margin-right: 2rem; } } @@ -90,7 +86,7 @@ position: absolute; left: -1.5rem; top: 0; - width: calc(100% + 1.5rem); + width: calc(100% + 3rem); margin: 0; z-index: 0; diff --git a/src/components/calls/group/GroupCallParticipantMenu.tsx b/src/components/calls/group/GroupCallParticipantMenu.tsx index a492da704..28666d751 100644 --- a/src/components/calls/group/GroupCallParticipantMenu.tsx +++ b/src/components/calls/group/GroupCallParticipantMenu.tsx @@ -1,17 +1,19 @@ import type { GroupCallParticipant } from '../../../lib/secret-sauce'; import type { FC } from '../../../lib/teact/teact'; import React, { - memo, useEffect, useState, + memo, useCallback, 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'; @@ -26,15 +28,9 @@ const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34]; type OwnProps = { participant?: GroupCallParticipant; - onCloseAnimationEnd: VoidFunction; - onClose: VoidFunction; + closeDropdown: VoidFunction; isDropdownOpen: boolean; - positionX?: 'left' | 'right'; - positionY?: 'top' | 'bottom'; - transformOriginX?: number; - transformOriginY?: number; - style?: string; - menuRef?: React.RefObject; + anchor?: IAnchorPosition; }; type StateProps = { @@ -52,16 +48,10 @@ const SPEAKER_ICON_SIZE = 24; const GroupCallParticipantMenu: FC = ({ participant, - onCloseAnimationEnd, - onClose, + closeDropdown, isDropdownOpen, + anchor, isAdmin, - positionY, - menuRef, - positionX, - style, - transformOriginY, - transformOriginX, }) => { const { toggleGroupCallMute, @@ -85,22 +75,6 @@ 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 @@ -111,49 +85,49 @@ const GroupCallParticipantMenu: FC = ({ const runThrottled = useRunThrottled(VOLUME_CHANGE_THROTTLE); - const handleRemove = useLastCallback((e: React.SyntheticEvent) => { + const handleRemove = useCallback((e: React.SyntheticEvent) => { e.stopPropagation(); openDeleteUserModal(); - onClose(); - }); + closeDropdown(); + }, [openDeleteUserModal, closeDropdown]); - const handleCancelRequestToSpeak = useLastCallback((e: React.SyntheticEvent) => { + const handleCancelRequestToSpeak = useCallback((e: React.SyntheticEvent) => { e.stopPropagation(); requestToSpeak({ value: false, }); - onClose(); - }); + closeDropdown(); + }, [requestToSpeak, closeDropdown]); - const handleMute = useLastCallback((e: React.SyntheticEvent) => { + const handleMute = useCallback((e: React.SyntheticEvent) => { e.stopPropagation(); - onClose(); + closeDropdown(); if (!isAdmin) { - 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); + setLocalVolume(isMutedByMe ? GROUP_CALL_DEFAULT_VOLUME / GROUP_CALL_VOLUME_MULTIPLIER : VOLUME_ZERO); } toggleGroupCallMute({ participantId: id!, value: isAdmin ? !shouldRaiseHand : !isMutedByMe, }); - }); + }, [closeDropdown, toggleGroupCallMute, id, isAdmin, shouldRaiseHand, isMutedByMe]); - const handleOpenProfile = useLastCallback((e: React.SyntheticEvent) => { + const handleOpenProfile = useCallback((e: React.SyntheticEvent) => { e.stopPropagation(); toggleGroupCallPanel(); openChat({ id, }); - onClose(); - }); + closeDropdown(); + }, [toggleGroupCallPanel, closeDropdown, openChat, id]); + + const isLocalVolumeZero = localVolume === VOLUME_ZERO; + const speakerIconPlaySegment = isLocalVolumeZero ? SPEAKER_ICON_DISABLED_SEGMENT : SPEAKER_ICON_ENABLED_SEGMENT; const handleChangeVolume = (e: React.ChangeEvent) => { const value = Number(e.target.value); - handleSetLocalVolume(value); - + setLocalVolume(value); runThrottled(() => { if (value === VOLUME_ZERO) { toggleGroupCallMute({ @@ -173,16 +147,11 @@ const GroupCallParticipantMenu: FC = ({
{!isSelf && !shouldRaiseHand && (
@@ -204,7 +173,6 @@ const GroupCallParticipantMenu: FC = ({
diff --git a/src/components/calls/group/GroupCallParticipantStreams.tsx b/src/components/calls/group/GroupCallParticipantStreams.tsx new file mode 100644 index 000000000..f5f11f0af --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantStreams.tsx @@ -0,0 +1,105 @@ +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 deleted file mode 100644 index 173c63718..000000000 --- a/src/components/calls/group/GroupCallParticipantVideo.module.scss +++ /dev/null @@ -1,150 +0,0 @@ -.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 new file mode 100644 index 000000000..2918592e5 --- /dev/null +++ b/src/components/calls/group/GroupCallParticipantVideo.scss @@ -0,0 +1,126 @@ +.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 46857ee95..e09f3ef36 100644 --- a/src/components/calls/group/GroupCallParticipantVideo.tsx +++ b/src/components/calls/group/GroupCallParticipantVideo.tsx @@ -1,314 +1,77 @@ -import React, { - memo, useCallback, useEffect, useMemo, useRef, useState, -} from '../../../lib/teact/teact'; +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 { 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_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config'; -import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce'; +import { GROUP_CALL_THUMB_VIDEO_DISABLED } from '../../../config'; 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 Button from '../../ui/Button'; -import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon'; -import FullNameTitle from '../../common/FullNameTitle'; -import GroupCallParticipantMenu from './GroupCallParticipantMenu'; +import Avatar from '../../common/Avatar'; -import styles from './GroupCallParticipantVideo.module.scss'; - -const VIDEO_FALLBACK_UPDATE_INTERVAL = 1000; +import './GroupCallParticipantVideo.scss'; type OwnProps = { - layout: VideoLayout; - setPinned: (participant?: VideoParticipant) => void; - pinnedVideo: VideoParticipant | undefined; - canPin: boolean; participant: TypeGroupCallParticipant; - className?: string; + type: 'video' | 'presentation'; + onClick?: (id: string, type: 'video' | 'presentation') => void; + isFullscreen?: boolean; }; type StateProps = { user?: ApiUser; chat?: ApiChat; + currentUserId?: string; + isActive?: boolean; }; const GroupCallParticipantVideo: FC = ({ - layout, - pinnedVideo, - setPinned, - canPin, - className, - participant, + type, + onClick, user, chat, + isActive, + isFullscreen, }) => { const lang = useLang(); - // 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); - - 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'); + const handleClick = useCallback(() => { + if (onClick) { + onClick(user?.id || chat!.id, type); } + }, [chat, onClick, type, user?.id]); - 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); + if (!user && !chat) return undefined; 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 (
-
- {stream && ( -