Group Calls: Redesign (#2859)
This commit is contained in:
parent
750cb2689b
commit
8fc05e97ff
Binary file not shown.
Binary file not shown.
296
src/components/calls/group/GroupCall.module.scss
Normal file
296
src/components/calls/group/GroupCall.module.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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, {
|
import React, {
|
||||||
memo, useCallback, useEffect, useMemo, useRef, useState,
|
memo, useEffect, useMemo, useRef, useState,
|
||||||
} from '../../../lib/teact/teact';
|
} from '../../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
import '../../../global/actions/calls';
|
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 {
|
import { IS_SCREENSHARE_SUPPORTED } from '../../../lib/secret-sauce';
|
||||||
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 buildClassName from '../../../util/buildClassName';
|
||||||
import {
|
import {
|
||||||
|
selectCanInviteToActiveGroupCall,
|
||||||
selectGroupCall,
|
selectGroupCall,
|
||||||
selectGroupCallParticipant,
|
selectGroupCallParticipant,
|
||||||
selectIsAdminInActiveGroupCall,
|
selectIsAdminInActiveGroupCall,
|
||||||
} from '../../../global/selectors/calls';
|
} 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 useFlag from '../../../hooks/useFlag';
|
||||||
import useLang from '../../../hooks/useLang';
|
import useLang from '../../../hooks/useLang';
|
||||||
import useAppLayout from '../../../hooks/useAppLayout';
|
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 Button from '../../ui/Button';
|
||||||
import DropdownMenu from '../../ui/DropdownMenu';
|
|
||||||
import MenuItem from '../../ui/MenuItem';
|
|
||||||
import Modal from '../../ui/Modal';
|
import Modal from '../../ui/Modal';
|
||||||
import MicrophoneButton from './MicrophoneButton';
|
import MicrophoneButton from './MicrophoneButton';
|
||||||
import AnimatedIcon from '../../common/AnimatedIcon';
|
|
||||||
import Checkbox from '../../ui/Checkbox';
|
import Checkbox from '../../ui/Checkbox';
|
||||||
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
|
|
||||||
import GroupCallParticipantList from './GroupCallParticipantList';
|
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 INTERSECTION_THROTTLE = 200;
|
||||||
const PARTICIPANT_HEIGHT = 60;
|
|
||||||
|
|
||||||
export type OwnProps = {
|
export type OwnProps = {
|
||||||
groupCallId: string;
|
groupCallId: string;
|
||||||
@ -57,21 +49,21 @@ type StateProps = {
|
|||||||
title?: string;
|
title?: string;
|
||||||
meParticipant?: TypeGroupCallParticipant;
|
meParticipant?: TypeGroupCallParticipant;
|
||||||
participantsCount?: number;
|
participantsCount?: number;
|
||||||
isSpeakerEnabled?: boolean;
|
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
participants: Record<string, TypeGroupCallParticipant>;
|
participants: Record<string, TypeGroupCallParticipant>;
|
||||||
|
canInvite: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupCall: FC<OwnProps & StateProps> = ({
|
const GroupCall: FC<OwnProps & StateProps> = ({
|
||||||
groupCallId,
|
groupCallId,
|
||||||
isCallPanelVisible,
|
isCallPanelVisible,
|
||||||
connectionState,
|
connectionState,
|
||||||
isSpeakerEnabled,
|
participantsCount,
|
||||||
title,
|
title,
|
||||||
meParticipant,
|
meParticipant,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
participants,
|
participants,
|
||||||
|
canInvite,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
toggleGroupCallVideo,
|
toggleGroupCallVideo,
|
||||||
@ -80,24 +72,60 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
|||||||
toggleGroupCallPanel,
|
toggleGroupCallPanel,
|
||||||
connectToActiveGroupCall,
|
connectToActiveGroupCall,
|
||||||
playGroupCallSound,
|
playGroupCallSound,
|
||||||
|
createGroupCallInviteLink,
|
||||||
} = getActions();
|
} = getActions();
|
||||||
|
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { isMobile, isLandscape } = useAppLayout();
|
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const primaryVideoContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const secondaryVideoContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const panelScrollTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [isLeaving, setIsLeaving] = useState(false);
|
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 [isFullscreen, openFullscreen, closeFullscreen] = useFlag();
|
||||||
const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true);
|
const [isSidebarOpen, openSidebar, closeSidebar] = useFlag(true);
|
||||||
const hasVideoParticipants = Object.values(participants).some(({ video, presentation }) => video || presentation);
|
const isLandscapeLayout = Boolean(isFullscreen && isLandscape);
|
||||||
const isLandscapeLayout = isFullscreen && (!isMobile || isLandscape) && hasVideoParticipants;
|
|
||||||
|
|
||||||
const [participantMenu, setParticipantMenu] = useState<{
|
const firstPresentation = useMemo(() => {
|
||||||
participant: TypeGroupCallParticipant;
|
return Object.values(participants).find(({ presentation }) => presentation);
|
||||||
anchor: IAnchorPosition;
|
}, [participants]);
|
||||||
} | undefined>();
|
const videoParticipants = useMemo(() => Object.values(participants)
|
||||||
const [isParticipantMenuOpen, openParticipantMenu, closeParticipantMenu] = useFlag();
|
.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 [isConfirmLeaveModalOpen, openConfirmLeaveModal, closeConfirmLeaveModal] = useFlag();
|
||||||
const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false);
|
const [isEndGroupCallModal, setIsEndGroupCallModal] = useState(false);
|
||||||
@ -105,20 +133,10 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
|||||||
|
|
||||||
const hasVideo = meParticipant?.hasVideoStream;
|
const hasVideo = meParticipant?.hasVideoStream;
|
||||||
const hasPresentation = meParticipant?.hasPresentationStream;
|
const hasPresentation = meParticipant?.hasPresentationStream;
|
||||||
|
const hasAudioStream = meParticipant?.hasAudioStream;
|
||||||
const isConnecting = connectionState !== 'connected';
|
const isConnecting = connectionState !== 'connected';
|
||||||
const canSelfUnmute = meParticipant?.canSelfUnmute;
|
const canSelfUnmute = meParticipant?.canSelfUnmute;
|
||||||
const shouldRaiseHand = !canSelfUnmute && meParticipant?.isMuted;
|
const canRequestToSpeak = !canSelfUnmute && !hasAudioStream;
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (connectionState === 'connected') {
|
if (connectionState === 'connected') {
|
||||||
@ -126,263 +144,360 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
|||||||
} else if (connectionState === 'reconnecting') {
|
} else if (connectionState === 'reconnecting') {
|
||||||
playGroupCallSound({ sound: 'connecting' });
|
playGroupCallSound({ sound: 'connecting' });
|
||||||
}
|
}
|
||||||
}, [connectionState, playGroupCallSound]);
|
}, [connectionState]);
|
||||||
|
|
||||||
const handleCloseConfirmLeaveModal = useCallback(() => {
|
const handleCloseConfirmLeaveModal = useLastCallback(() => {
|
||||||
closeConfirmLeaveModal();
|
closeConfirmLeaveModal();
|
||||||
setIsEndGroupCallModal(false);
|
setIsEndGroupCallModal(false);
|
||||||
}, [closeConfirmLeaveModal]);
|
});
|
||||||
|
|
||||||
const MainButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
|
const handleToggleFullscreen = useLastCallback(() => {
|
||||||
return ({ onTrigger, isOpen }) => (
|
|
||||||
<Button
|
|
||||||
round
|
|
||||||
size="smaller"
|
|
||||||
color="translucent"
|
|
||||||
className={isOpen ? 'active' : undefined}
|
|
||||||
onClick={onTrigger}
|
|
||||||
ariaLabel={lang('AccDescrMoreOptions')}
|
|
||||||
>
|
|
||||||
<i className="icon icon-more" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}, [lang]);
|
|
||||||
|
|
||||||
const handleToggleFullscreen = useCallback(() => {
|
|
||||||
if (!containerRef.current) return;
|
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) {
|
if (isFullscreen) {
|
||||||
closeFullscreen();
|
closeFullscreen();
|
||||||
} else {
|
} else {
|
||||||
openFullscreen();
|
openFullscreen();
|
||||||
}
|
}
|
||||||
}, [closeFullscreen, isFullscreen, openFullscreen]);
|
});
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleToggleSidebar = useLastCallback(() => {
|
||||||
toggleGroupCallPanel();
|
if (isSidebarOpen) {
|
||||||
if (isFullscreen) {
|
closeSidebar();
|
||||||
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 {
|
} else {
|
||||||
toggleGroupCallVideo();
|
openSidebar();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
|
const handleInviteMember = useLastCallback(() => {
|
||||||
|
createGroupCallInviteLink();
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClickVideo = useLastCallback(() => {
|
||||||
|
toggleGroupCallVideo();
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connectToActiveGroupCall();
|
connectToActiveGroupCall();
|
||||||
}, [connectToActiveGroupCall, groupCallId]);
|
}, [connectToActiveGroupCall, groupCallId]);
|
||||||
|
|
||||||
const endGroupCall = useCallback(() => {
|
const handleLeaveGroupCall = useLastCallback(() => {
|
||||||
setIsEndGroupCallModal(true);
|
|
||||||
setShouldEndGroupCall(true);
|
|
||||||
openConfirmLeaveModal();
|
|
||||||
if (isFullscreen) {
|
|
||||||
handleToggleFullscreen();
|
|
||||||
}
|
|
||||||
}, [handleToggleFullscreen, isFullscreen, openConfirmLeaveModal]);
|
|
||||||
|
|
||||||
const handleLeaveGroupCall = useCallback(() => {
|
|
||||||
if (isAdmin && !isConfirmLeaveModalOpen) {
|
if (isAdmin && !isConfirmLeaveModalOpen) {
|
||||||
openConfirmLeaveModal();
|
openConfirmLeaveModal();
|
||||||
if (isFullscreen) {
|
|
||||||
handleToggleFullscreen();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
playGroupCallSound({ sound: 'leave' });
|
playGroupCallSound({ sound: 'leave' });
|
||||||
setIsLeaving(true);
|
setIsLeaving(true);
|
||||||
closeConfirmLeaveModal();
|
closeConfirmLeaveModal();
|
||||||
}, [
|
});
|
||||||
closeConfirmLeaveModal, handleToggleFullscreen, isAdmin, isConfirmLeaveModalOpen, isFullscreen,
|
|
||||||
openConfirmLeaveModal, playGroupCallSound,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCloseAnimationEnd = useCallback(() => {
|
const handleCloseAnimationEnd = useLastCallback(() => {
|
||||||
if (isLeaving) {
|
if (!isLeaving) return;
|
||||||
leaveGroupCall({
|
|
||||||
shouldDiscard: shouldEndGroupCall,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isLeaving, leaveGroupCall, shouldEndGroupCall]);
|
|
||||||
|
|
||||||
const handleToggleGroupCallPresentation = useCallback(() => {
|
leaveGroupCall({
|
||||||
|
shouldDiscard: shouldEndGroupCall,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleGroupCallPresentation = useLastCallback(() => {
|
||||||
toggleGroupCallPresentation();
|
toggleGroupCallPresentation();
|
||||||
}, [toggleGroupCallPresentation]);
|
});
|
||||||
|
|
||||||
|
const canPinVideo = videoParticipants.length > 1 && isLandscapeLayout;
|
||||||
|
const isLandscapeWithVideos = isLandscapeLayout && hasVideoParticipants;
|
||||||
|
const [pinnedVideo, setPinnedVideo] = useState<VideoParticipant | undefined>(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 (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={!isCallPanelVisible && !isLeaving}
|
isOpen={isOpen}
|
||||||
onClose={toggleGroupCallPanel}
|
onClose={toggleGroupCallPanel}
|
||||||
className={buildClassName(
|
className={buildClassName(
|
||||||
'GroupCall',
|
styles.root,
|
||||||
(isMobile && !isLandscape) && 'single-column',
|
isFullscreen && styles.fullscreen,
|
||||||
isLandscapeLayout && 'landscape',
|
isLandscapeLayout && styles.landscape,
|
||||||
!isSidebarOpen && 'no-sidebar',
|
!hasVideoParticipants && styles.noVideoParticipants,
|
||||||
|
!isLandscapeLayout && styles.portrait,
|
||||||
|
!isSidebarOpen && isLandscapeWithVideos && styles.noSidebar,
|
||||||
)}
|
)}
|
||||||
dialogRef={containerRef}
|
dialogRef={containerRef}
|
||||||
onCloseAnimationEnd={handleCloseAnimationEnd}
|
onCloseAnimationEnd={handleCloseAnimationEnd}
|
||||||
>
|
>
|
||||||
<div className="header">
|
{isLandscapeWithVideos && (
|
||||||
<h3>{title || lang('VoipGroupVoiceChat')}</h3>
|
<div className={styles.videos}>
|
||||||
{IS_REQUEST_FULLSCREEN_SUPPORTED && (
|
<div className={styles.videosHeader}>
|
||||||
<Button
|
<Button
|
||||||
round
|
round
|
||||||
size="smaller"
|
size="smaller"
|
||||||
color="translucent"
|
color="translucent"
|
||||||
onClick={handleToggleFullscreen}
|
onClick={handleToggleFullscreen}
|
||||||
ariaLabel={lang(isFullscreen ? 'AccExitFullscreen' : 'AccSwitchToFullscreen')}
|
className={buildClassName(styles.headerButton, styles.firstButton)}
|
||||||
>
|
ariaLabel={lang(isFullscreen ? 'AccExitFullscreen' : 'AccSwitchToFullscreen')}
|
||||||
<i className={buildClassName('icon', isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen')} />
|
>
|
||||||
</Button>
|
<i
|
||||||
)}
|
className={buildClassName('icon', isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen')}
|
||||||
{isLandscapeLayout && (
|
aria-hidden
|
||||||
<Button
|
/>
|
||||||
round
|
</Button>
|
||||||
size="smaller"
|
|
||||||
color="translucent"
|
<h3 className={buildClassName(styles.title, styles.bigger)}>
|
||||||
onClick={handleToggleSidebar}
|
{title || lang('VoipGroupVoiceChat')}
|
||||||
>
|
</h3>
|
||||||
<i className="icon icon-sidebar" />
|
|
||||||
</Button>
|
{isLandscapeWithVideos && !isSidebarOpen && (
|
||||||
)}
|
<Button
|
||||||
{((IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand) || isAdmin) && (
|
round
|
||||||
<DropdownMenu
|
size="smaller"
|
||||||
positionX="right"
|
color="translucent"
|
||||||
trigger={MainButton}
|
className={buildClassName(styles.headerButton, styles.videosHeaderLastButton)}
|
||||||
>
|
onClick={handleToggleSidebar}
|
||||||
{IS_SCREENSHARE_SUPPORTED && !shouldRaiseHand && (
|
ariaLabel={lang('AccDescrExpandPanel')}
|
||||||
<MenuItem
|
|
||||||
icon="share-screen-outlined"
|
|
||||||
onClick={handleToggleGroupCallPresentation}
|
|
||||||
>
|
>
|
||||||
{lang(hasPresentation ? 'VoipChatStopScreenCapture' : 'VoipChatStartScreenCapture')}
|
<i className="icon icon-sidebar" aria-hidden />
|
||||||
</MenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
</div>
|
||||||
<MenuItem
|
|
||||||
icon="phone-discard-outline"
|
<div
|
||||||
onClick={endGroupCall}
|
className={styles.videosContent}
|
||||||
destructive
|
ref={primaryVideoContainerRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.panelWrapper} ref={panelRef}>
|
||||||
|
<div className={buildClassName(styles.panel, 'custom-scroll')}>
|
||||||
|
<div className={styles.panelScrollTrigger} ref={panelScrollTriggerRef} />
|
||||||
|
|
||||||
|
<div className={buildClassName(styles.panelHeader, hasScrolled && styles.scrolled)}>
|
||||||
|
{!isLandscapeWithVideos && (
|
||||||
|
<Button
|
||||||
|
round
|
||||||
|
size="smaller"
|
||||||
|
color="translucent"
|
||||||
|
ripple={!isMobile}
|
||||||
|
className={buildClassName(
|
||||||
|
styles.firstButton,
|
||||||
|
styles.headerButton,
|
||||||
|
)}
|
||||||
|
onClick={handleToggleFullscreen}
|
||||||
|
ariaLabel={lang('AccSwitchToFullscreen')}
|
||||||
>
|
>
|
||||||
{lang('VoipGroupLeaveAlertEndChat')}
|
<i className="icon icon-fullscreen" aria-hidden />
|
||||||
</MenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
{isLandscapeWithVideos && (
|
||||||
|
<Button
|
||||||
|
round
|
||||||
|
size="smaller"
|
||||||
|
ripple={!isMobile}
|
||||||
|
className={buildClassName(
|
||||||
|
styles.firstButton,
|
||||||
|
styles.headerButton,
|
||||||
|
)}
|
||||||
|
color="translucent"
|
||||||
|
onClick={handleToggleSidebar}
|
||||||
|
ariaLabel={lang('AccDescrCollapsePanel')}
|
||||||
|
>
|
||||||
|
<i className="icon icon-sidebar" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.panelHeaderText}>
|
||||||
|
<h3 className={buildClassName(styles.title, isLandscapeWithVideos && styles.bigger)}>
|
||||||
|
{isLandscapeWithVideos ? membersString : groupCallTitle}
|
||||||
|
</h3>
|
||||||
|
{!isLandscapeWithVideos && (
|
||||||
|
<span className={styles.subtitle}>
|
||||||
|
{membersString}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLandscapeWithVideos && canInvite && (
|
||||||
|
<Button
|
||||||
|
round
|
||||||
|
size="smaller"
|
||||||
|
ripple={!isMobile}
|
||||||
|
className={buildClassName(
|
||||||
|
styles.lastButton,
|
||||||
|
styles.headerButton,
|
||||||
|
)}
|
||||||
|
color="translucent"
|
||||||
|
onClick={handleInviteMember}
|
||||||
|
ariaLabel={lang('VoipGroupInviteMember')}
|
||||||
|
>
|
||||||
|
<i className="icon icon-add-user" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.participants}>
|
||||||
|
<div
|
||||||
|
className={styles.participantVideos}
|
||||||
|
ref={secondaryVideoContainerRef}
|
||||||
|
style={`height: ${panelOffset}px;`}
|
||||||
|
>
|
||||||
|
{videoLayout.map((layout) => {
|
||||||
|
const participant = participants[layout.participantId];
|
||||||
|
if (!layout.isRemounted || !participant) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
teactOrderKey={layout.orderKey}
|
||||||
|
key={`${layout.participantId}_${layout.type}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GroupCallParticipantVideo
|
||||||
|
teactOrderKey={layout.orderKey}
|
||||||
|
key={`${layout.participantId}_${layout.type}`}
|
||||||
|
layout={layout}
|
||||||
|
canPin={canPinVideo}
|
||||||
|
setPinned={setPinnedVideo}
|
||||||
|
pinnedVideo={pinnedVideo}
|
||||||
|
participant={participant}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<GroupCallParticipantList
|
||||||
|
panelOffset={panelOffset}
|
||||||
|
isLandscape={isLandscapeWithVideos}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FloatingActionButton
|
||||||
|
key="add-participant"
|
||||||
|
isShown={isLandscapeWithVideos && canInvite}
|
||||||
|
onClick={handleInviteMember}
|
||||||
|
className={styles.addParticipantButton}
|
||||||
|
ariaLabel={lang('VoipGroupInviteMember')}
|
||||||
|
>
|
||||||
|
<i className="icon icon-add-user-filled" aria-hidden />
|
||||||
|
</FloatingActionButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{videoLayout.map((layout) => {
|
||||||
|
const participant = participants[layout.participantId];
|
||||||
|
if (layout.isRemounted || !participant) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
teactOrderKey={layout.orderKey}
|
||||||
|
key={`${layout.participantId}_${layout.type}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<GroupCallParticipantVideo
|
||||||
|
teactOrderKey={layout.orderKey}
|
||||||
|
key={`${layout.participantId}_${layout.type}`}
|
||||||
|
layout={layout}
|
||||||
|
canPin={canPinVideo}
|
||||||
|
setPinned={setPinnedVideo}
|
||||||
|
pinnedVideo={pinnedVideo}
|
||||||
|
participant={participant}
|
||||||
|
className={styles.video}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
<Button
|
<Button
|
||||||
round
|
round
|
||||||
size="smaller"
|
size="default"
|
||||||
color="translucent"
|
ripple
|
||||||
onClick={handleClose}
|
className={buildClassName(
|
||||||
|
styles.actionButton,
|
||||||
|
!hasAudioStream && styles.muted,
|
||||||
|
canRequestToSpeak && styles.canRequestToSpeak,
|
||||||
|
)}
|
||||||
|
onClick={handleClickVideo}
|
||||||
|
ariaLabel={lang(hasVideo ? 'VoipStopVideo' : 'VoipStartVideo')}
|
||||||
|
disabled={isConnecting}
|
||||||
>
|
>
|
||||||
<i className="icon icon-close" />
|
<i className={buildClassName('icon', !hasVideo ? 'icon-video-stop' : 'icon-video')} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="scrollable custom-scroll">
|
<Button
|
||||||
<GroupCallParticipantStreams onDoubleClick={handleStreamsDoubleClick} />
|
round
|
||||||
|
size="default"
|
||||||
|
ripple
|
||||||
|
className={buildClassName(
|
||||||
|
styles.actionButton,
|
||||||
|
!hasAudioStream && styles.muted,
|
||||||
|
canRequestToSpeak && styles.canRequestToSpeak,
|
||||||
|
)}
|
||||||
|
onClick={handleToggleGroupCallPresentation}
|
||||||
|
ariaLabel={lang(hasPresentation ? 'lng_group_call_screen_share_stop' : 'lng_group_call_tooltip_screen')}
|
||||||
|
disabled={isConnecting || !IS_SCREENSHARE_SUPPORTED}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={buildClassName('icon', !hasPresentation ? 'icon-share-screen-stop' : 'icon-share-screen')}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
{(!isLandscapeLayout || isSidebarOpen)
|
<MicrophoneButton className={styles.actionButton} />
|
||||||
&& <GroupCallParticipantList openParticipantMenu={handleOpenParticipantMenu} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GroupCallParticipantMenu
|
<Button
|
||||||
participant={participantMenu?.participant}
|
round
|
||||||
anchor={participantMenu?.anchor}
|
size="default"
|
||||||
isDropdownOpen={isParticipantMenuOpen}
|
ripple
|
||||||
closeDropdown={closeParticipantMenu}
|
className={buildClassName(
|
||||||
/>
|
styles.actionButton,
|
||||||
|
!hasAudioStream && styles.muted,
|
||||||
|
canRequestToSpeak && styles.canRequestToSpeak,
|
||||||
|
)}
|
||||||
|
ariaLabel={lang('lng_group_call_settings')}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<i className="icon icon-settings-filled" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<div className="buttons">
|
<Button
|
||||||
{isConnecting && <Loading />}
|
round
|
||||||
|
size="default"
|
||||||
<div className="button-wrapper">
|
ripple
|
||||||
<div className="video-buttons">
|
className={buildClassName(
|
||||||
{hasVideo && (IS_ANDROID || IS_IOS) && (
|
styles.actionButton,
|
||||||
<button className="smaller-button" onClick={switchCameraInput}>
|
styles.destructive,
|
||||||
<AnimatedIcon
|
)}
|
||||||
tgsUrl={LOCAL_TGS_URLS.CameraFlip}
|
onClick={handleLeaveGroupCall}
|
||||||
playSegment={CAMERA_FLIP_PLAY_SEGMENT}
|
ariaLabel={lang('lng_group_call_leave')}
|
||||||
size={24}
|
>
|
||||||
/>
|
<i className="icon icon-close" aria-hidden />
|
||||||
</button>
|
</Button>
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className={buildClassName(
|
|
||||||
'small-button',
|
|
||||||
shouldRaiseHand ? 'speaker' : 'camera',
|
|
||||||
(hasVideo || (shouldRaiseHand && isSpeakerEnabled)) && 'active',
|
|
||||||
)}
|
|
||||||
onClick={handleClickVideoOrSpeaker}
|
|
||||||
>
|
|
||||||
<i className={buildClassName(
|
|
||||||
'icon',
|
|
||||||
shouldRaiseHand ? 'icon-speaker' : (hasVideo ? 'icon-video-stop' : 'icon-video'),
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="button-text">
|
|
||||||
{lang(shouldRaiseHand ? 'VoipSpeaker' : 'VoipCamera')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MicrophoneButton />
|
|
||||||
|
|
||||||
<div className="button-wrapper">
|
|
||||||
<button className="small-button leave" onClick={handleLeaveGroupCall}>
|
|
||||||
<i className="icon icon-phone-discard" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="button-text">
|
|
||||||
{lang('VoipGroupLeave')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@ -415,18 +530,20 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
|||||||
export default memo(withGlobal<OwnProps>(
|
export default memo(withGlobal<OwnProps>(
|
||||||
(global, { groupCallId }): StateProps => {
|
(global, { groupCallId }): StateProps => {
|
||||||
const {
|
const {
|
||||||
connectionState, title, isSpeakerDisabled, participants, participantsCount,
|
connectionState, title, participants, participantsCount, chatId,
|
||||||
} = selectGroupCall(global, groupCallId)! || {};
|
} = selectGroupCall(global, groupCallId)! || {};
|
||||||
|
|
||||||
|
const chat = chatId ? selectChat(global, chatId) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connectionState,
|
connectionState,
|
||||||
title,
|
title: title || chat?.title,
|
||||||
isSpeakerEnabled: !isSpeakerDisabled,
|
|
||||||
participantsCount,
|
participantsCount,
|
||||||
meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!),
|
meParticipant: selectGroupCallParticipant(global, groupCallId, global.currentUserId!),
|
||||||
isCallPanelVisible: Boolean(selectTabState(global).isCallPanelVisible),
|
isCallPanelVisible: Boolean(selectTabState(global).isCallPanelVisible),
|
||||||
isAdmin: selectIsAdminInActiveGroupCall(global),
|
isAdmin: selectIsAdminInActiveGroupCall(global),
|
||||||
participants,
|
participants,
|
||||||
|
canInvite: selectCanInviteToActiveGroupCall(global),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
)(GroupCall));
|
)(GroupCall));
|
||||||
|
|||||||
55
src/components/calls/group/GroupCallParticipant.module.scss
Normal file
55
src/components/calls/group/GroupCallParticipant.module.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +1,31 @@
|
|||||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
||||||
import { THRESHOLD } from '../../../lib/secret-sauce';
|
import { THRESHOLD } from '../../../lib/secret-sauce';
|
||||||
import type { FC } from '../../../lib/teact/teact';
|
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 { withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { ApiChat, ApiUser } from '../../../api/types';
|
import type { ApiChat, ApiUser } from '../../../api/types';
|
||||||
|
|
||||||
|
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
|
import renderText from '../../common/helpers/renderText';
|
||||||
import { selectChat, selectUser } from '../../../global/selectors';
|
import { selectChat, selectUser } from '../../../global/selectors';
|
||||||
import useLang from '../../../hooks/useLang';
|
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 Avatar from '../../common/Avatar';
|
||||||
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
|
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 = {
|
type OwnProps = {
|
||||||
participant: TypeGroupCallParticipant;
|
participant: TypeGroupCallParticipant;
|
||||||
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateProps = {
|
type StateProps = {
|
||||||
@ -27,67 +34,132 @@ type StateProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GroupCallParticipant: FC<OwnProps & StateProps> = ({
|
const GroupCallParticipant: FC<OwnProps & StateProps> = ({
|
||||||
openParticipantMenu,
|
|
||||||
participant,
|
participant,
|
||||||
user,
|
user,
|
||||||
chat,
|
chat,
|
||||||
}) => {
|
}) => {
|
||||||
// eslint-disable-next-line no-null/no-null
|
// eslint-disable-next-line no-null/no-null
|
||||||
const anchorRef = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
|
||||||
const { isSelf, isMutedByMe, isMuted } = participant;
|
const {
|
||||||
|
isSelf, isMutedByMe, isMuted, hasVideoStream, hasPresentationStream,
|
||||||
|
} = participant;
|
||||||
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
|
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
|
||||||
const isRaiseHand = Boolean(participant.raiseHandRating);
|
const isRaiseHand = Boolean(participant.raiseHandRating);
|
||||||
|
|
||||||
const handleOnClick = () => {
|
const {
|
||||||
if (isSelf) return;
|
isContextMenuOpen,
|
||||||
openParticipantMenu(anchorRef.current!, participant);
|
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(() => {
|
const [aboutText, aboutColor] = useMemo(() => {
|
||||||
if (isSelf) {
|
|
||||||
return [lang('ThisIsYou'), 'blue'];
|
|
||||||
}
|
|
||||||
if (isMutedByMe) {
|
if (isMutedByMe) {
|
||||||
return [lang('VoipGroupMutedForMe'), 'red'];
|
return [lang('VoipGroupMutedForMe'), styles.subtitleRed];
|
||||||
}
|
}
|
||||||
return isRaiseHand
|
|
||||||
? [lang('WantsToSpeak'), 'blue']
|
if (isRaiseHand) {
|
||||||
: (!isMuted && isSpeaking ? [
|
return [lang('WantsToSpeak'), styles.subtitleBlue];
|
||||||
participant.volume && participant.volume !== GROUP_CALL_DEFAULT_VOLUME
|
}
|
||||||
? lang('SpeakingWithVolume',
|
|
||||||
(participant.volume / GROUP_CALL_VOLUME_MULTIPLIER).toString())
|
if (hasCustomVolume) {
|
||||||
.replace('%%', '%') : lang('Speaking'),
|
return [
|
||||||
'green',
|
lang('SpeakingWithVolume',
|
||||||
]
|
(participant.volume! / GROUP_CALL_VOLUME_MULTIPLIER).toString())
|
||||||
: (participant.about ? [participant.about, ''] : [lang('Listening'), 'blue']));
|
.replace('%%', '%'),
|
||||||
}, [isSpeaking, participant.volume, lang, isSelf, isMutedByMe, isRaiseHand, isMuted, participant.about]);
|
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) {
|
if (!user && !chat) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = user ? `${user.firstName || ''} ${user.lastName || ''}` : chat?.title;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ListItem
|
||||||
className={buildClassName(
|
leftElement={<Avatar user={user} chat={chat} className={styles.avatar} />}
|
||||||
'GroupCallParticipant',
|
rightElement={<OutlinedMicrophoneIcon participant={participant} className={styles.icon} />}
|
||||||
participant.canSelfUnmute && 'can-self-unmute',
|
className={styles.root}
|
||||||
)}
|
onClick={handleContextMenu}
|
||||||
onClick={handleOnClick}
|
onMouseDown={handleBeforeContextMenu}
|
||||||
ref={anchorRef}
|
onContextMenu={handleContextMenu}
|
||||||
|
multiline
|
||||||
|
ripple
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Avatar user={user} chat={chat} size="medium" />
|
<FullNameTitle peer={user || chat!} withEmojiStatus className={styles.title} />
|
||||||
<div className="info">
|
<span className={buildClassName(styles.subtitle, 'subtitle', aboutColor)}>
|
||||||
<span className="name">{name}</span>
|
{hasPresentationStream && <i className="icon icon-share-screen" aria-hidden />}
|
||||||
<span className={buildClassName('about', aboutColor)}>{aboutText}</span>
|
{hasVideoStream && <i className="icon icon-video" aria-hidden />}
|
||||||
</div>
|
{hasCustomVolume && <i className="icon icon-speaker" aria-hidden />}
|
||||||
<div className="microphone">
|
<span className={styles.subtitleText}>{renderText(aboutText)}</span>
|
||||||
<OutlinedMicrophoneIcon participant={participant} />
|
</span>
|
||||||
</div>
|
<GroupCallParticipantMenu
|
||||||
</div>
|
participant={participant}
|
||||||
|
isDropdownOpen={isContextMenuOpen}
|
||||||
|
positionX={positionX}
|
||||||
|
positionY={positionY}
|
||||||
|
transformOriginX={transformOriginX}
|
||||||
|
transformOriginY={transformOriginY}
|
||||||
|
style={menuStyle}
|
||||||
|
onClose={handleContextMenuClose}
|
||||||
|
onCloseAnimationEnd={handleContextMenuHide}
|
||||||
|
menuRef={menuRef}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,10 @@
|
|||||||
|
.root {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
top: 0.25rem;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portrait {
|
||||||
|
padding-bottom: 6rem;
|
||||||
|
}
|
||||||
@ -1,17 +1,22 @@
|
|||||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
import React, { memo, useMemo } from '../../../lib/teact/teact';
|
||||||
import type { FC } from '../../../lib/teact/teact';
|
|
||||||
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
|
|
||||||
import { getActions, withGlobal } from '../../../global';
|
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 { selectActiveGroupCall } from '../../../global/selectors/calls';
|
||||||
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
|
import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
|
||||||
|
import useLastCallback from '../../../hooks/useLastCallback';
|
||||||
|
|
||||||
import GroupCallParticipant from './GroupCallParticipant';
|
import GroupCallParticipant from './GroupCallParticipant';
|
||||||
import InfiniteScroll from '../../ui/InfiniteScroll';
|
import InfiniteScroll from '../../ui/InfiniteScroll';
|
||||||
|
|
||||||
|
import styles from './GroupCallParticipantList.module.scss';
|
||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
openParticipantMenu: (anchor: HTMLDivElement, participant: TypeGroupCallParticipant) => void;
|
panelOffset: number;
|
||||||
|
isLandscape: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateProps = {
|
type StateProps = {
|
||||||
@ -21,24 +26,22 @@ type StateProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GroupCallParticipantList: FC<OwnProps & StateProps> = ({
|
const GroupCallParticipantList: FC<OwnProps & StateProps> = ({
|
||||||
|
panelOffset,
|
||||||
participants,
|
participants,
|
||||||
participantsCount,
|
participantsCount,
|
||||||
openParticipantMenu,
|
isLandscape,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
createGroupCallInviteLink,
|
|
||||||
loadMoreGroupCallParticipants,
|
loadMoreGroupCallParticipants,
|
||||||
} = getActions();
|
} = getActions();
|
||||||
|
|
||||||
const lang = useLang();
|
|
||||||
|
|
||||||
const participantsIds = useMemo(() => {
|
const participantsIds = useMemo(() => {
|
||||||
return Object.keys(participants || {});
|
return Object.keys(participants || {});
|
||||||
}, [participants]);
|
}, [participants]);
|
||||||
|
|
||||||
const handleLoadMoreGroupCallParticipants = useCallback(() => {
|
const handleLoadMoreGroupCallParticipants = useLastCallback(() => {
|
||||||
loadMoreGroupCallParticipants();
|
loadMoreGroupCallParticipants();
|
||||||
}, [loadMoreGroupCallParticipants]);
|
});
|
||||||
|
|
||||||
const [viewportIds, getMore] = useInfiniteScroll(
|
const [viewportIds, getMore] = useInfiniteScroll(
|
||||||
handleLoadMoreGroupCallParticipants,
|
handleLoadMoreGroupCallParticipants,
|
||||||
@ -46,37 +49,24 @@ const GroupCallParticipantList: FC<OwnProps & StateProps> = ({
|
|||||||
participantsIds.length >= participantsCount,
|
participantsIds.length >= participantsCount,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleCreateGroupCallInviteLink() {
|
|
||||||
createGroupCallInviteLink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="participants">
|
<InfiniteScroll
|
||||||
<div className="invite-btn" onClick={handleCreateGroupCallInviteLink}>
|
items={viewportIds}
|
||||||
<div className="icon-wrapper">
|
onLoadMore={getMore}
|
||||||
<i className="icon icon-add-user" />
|
style={`transform: translateY(${panelOffset}px);`}
|
||||||
</div>
|
className={buildClassName(styles.root, !isLandscape && styles.portrait)}
|
||||||
<div className="text">{lang('VoipGroupInviteMember')}</div>
|
>
|
||||||
</div>
|
{participants && viewportIds?.map(
|
||||||
|
(participantId) => (
|
||||||
<InfiniteScroll
|
participants[participantId] && (
|
||||||
items={viewportIds}
|
<GroupCallParticipant
|
||||||
onLoadMore={getMore}
|
key={participantId}
|
||||||
>
|
participant={participants[participantId]}
|
||||||
{viewportIds?.map(
|
/>
|
||||||
(participantId) => (
|
)
|
||||||
participants![participantId] && (
|
),
|
||||||
<GroupCallParticipant
|
)}
|
||||||
key={participantId}
|
</InfiniteScroll>
|
||||||
openParticipantMenu={openParticipantMenu}
|
|
||||||
participant={participants![participantId]}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</InfiniteScroll>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
@import '../../../styles/mixins';
|
@import '../../../styles/mixins';
|
||||||
|
|
||||||
.participant-menu {
|
.participant-menu {
|
||||||
position: absolute;
|
|
||||||
--color-text: white;
|
--color-text: white;
|
||||||
--color-background-compact-menu: #212121DD;
|
--color-background-compact-menu: #212121DD;
|
||||||
--color-background-compact-menu-hover: #00000066;
|
--color-background-compact-menu-hover: #00000066;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
z-index: var(--z-modal-menu);
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
|
backdrop-filter: none !important;
|
||||||
background: none !important;
|
background: none !important;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@ -21,6 +24,7 @@
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
border-radius: var(--border-radius-default);
|
border-radius: var(--border-radius-default);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +48,7 @@
|
|||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
|
||||||
.AnimatedSticker {
|
.AnimatedSticker {
|
||||||
margin-right: 2rem;
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,7 +90,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: -1.5rem;
|
left: -1.5rem;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: calc(100% + 3rem);
|
width: calc(100% + 1.5rem);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,17 @@
|
|||||||
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
|
import type { GroupCallParticipant } from '../../../lib/secret-sauce';
|
||||||
import type { FC } from '../../../lib/teact/teact';
|
import type { FC } from '../../../lib/teact/teact';
|
||||||
import React, {
|
import React, {
|
||||||
memo, useCallback, useEffect, useState,
|
memo, useEffect, useState,
|
||||||
} from '../../../lib/teact/teact';
|
} from '../../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
|
|
||||||
import type { IAnchorPosition } from '../../../types';
|
|
||||||
|
|
||||||
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
import { GROUP_CALL_DEFAULT_VOLUME, GROUP_CALL_VOLUME_MULTIPLIER } from '../../../config';
|
||||||
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
|
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
|
||||||
import buildClassName from '../../../util/buildClassName';
|
import buildClassName from '../../../util/buildClassName';
|
||||||
import buildStyle from '../../../util/buildStyle';
|
|
||||||
import useRunThrottled from '../../../hooks/useRunThrottled';
|
import useRunThrottled from '../../../hooks/useRunThrottled';
|
||||||
import useFlag from '../../../hooks/useFlag';
|
import useFlag from '../../../hooks/useFlag';
|
||||||
import useLang from '../../../hooks/useLang';
|
import useLang from '../../../hooks/useLang';
|
||||||
|
import useLastCallback from '../../../hooks/useLastCallback';
|
||||||
import { selectIsAdminInActiveGroupCall } from '../../../global/selectors/calls';
|
import { selectIsAdminInActiveGroupCall } from '../../../global/selectors/calls';
|
||||||
|
|
||||||
import Menu from '../../ui/Menu';
|
import Menu from '../../ui/Menu';
|
||||||
@ -28,9 +26,15 @@ const SPEAKER_ICON_ENABLED_SEGMENT: [number, number] = [17, 34];
|
|||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
participant?: GroupCallParticipant;
|
participant?: GroupCallParticipant;
|
||||||
closeDropdown: VoidFunction;
|
onCloseAnimationEnd: VoidFunction;
|
||||||
|
onClose: VoidFunction;
|
||||||
isDropdownOpen: boolean;
|
isDropdownOpen: boolean;
|
||||||
anchor?: IAnchorPosition;
|
positionX?: 'left' | 'right';
|
||||||
|
positionY?: 'top' | 'bottom';
|
||||||
|
transformOriginX?: number;
|
||||||
|
transformOriginY?: number;
|
||||||
|
style?: string;
|
||||||
|
menuRef?: React.RefObject<HTMLDivElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateProps = {
|
type StateProps = {
|
||||||
@ -48,10 +52,16 @@ const SPEAKER_ICON_SIZE = 24;
|
|||||||
|
|
||||||
const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
||||||
participant,
|
participant,
|
||||||
closeDropdown,
|
onCloseAnimationEnd,
|
||||||
|
onClose,
|
||||||
isDropdownOpen,
|
isDropdownOpen,
|
||||||
anchor,
|
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
positionY,
|
||||||
|
menuRef,
|
||||||
|
positionX,
|
||||||
|
style,
|
||||||
|
transformOriginY,
|
||||||
|
transformOriginX,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
toggleGroupCallMute,
|
toggleGroupCallMute,
|
||||||
@ -75,6 +85,22 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
|||||||
isMutedByMe ? VOLUME_ZERO : ((participant?.volume || GROUP_CALL_DEFAULT_VOLUME) / GROUP_CALL_VOLUME_MULTIPLIER),
|
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(() => {
|
useEffect(() => {
|
||||||
setLocalVolume(isMutedByMe
|
setLocalVolume(isMutedByMe
|
||||||
? VOLUME_ZERO
|
? VOLUME_ZERO
|
||||||
@ -85,49 +111,49 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
|||||||
|
|
||||||
const runThrottled = useRunThrottled(VOLUME_CHANGE_THROTTLE);
|
const runThrottled = useRunThrottled(VOLUME_CHANGE_THROTTLE);
|
||||||
|
|
||||||
const handleRemove = useCallback((e: React.SyntheticEvent<any>) => {
|
const handleRemove = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openDeleteUserModal();
|
openDeleteUserModal();
|
||||||
closeDropdown();
|
onClose();
|
||||||
}, [openDeleteUserModal, closeDropdown]);
|
});
|
||||||
|
|
||||||
const handleCancelRequestToSpeak = useCallback((e: React.SyntheticEvent<any>) => {
|
const handleCancelRequestToSpeak = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
requestToSpeak({
|
requestToSpeak({
|
||||||
value: false,
|
value: false,
|
||||||
});
|
});
|
||||||
closeDropdown();
|
onClose();
|
||||||
}, [requestToSpeak, closeDropdown]);
|
});
|
||||||
|
|
||||||
const handleMute = useCallback((e: React.SyntheticEvent<any>) => {
|
const handleMute = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
closeDropdown();
|
onClose();
|
||||||
|
|
||||||
if (!isAdmin) {
|
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({
|
toggleGroupCallMute({
|
||||||
participantId: id!,
|
participantId: id!,
|
||||||
value: isAdmin ? !shouldRaiseHand : !isMutedByMe,
|
value: isAdmin ? !shouldRaiseHand : !isMutedByMe,
|
||||||
});
|
});
|
||||||
}, [closeDropdown, toggleGroupCallMute, id, isAdmin, shouldRaiseHand, isMutedByMe]);
|
});
|
||||||
|
|
||||||
const handleOpenProfile = useCallback((e: React.SyntheticEvent<any>) => {
|
const handleOpenProfile = useLastCallback((e: React.SyntheticEvent<any>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleGroupCallPanel();
|
toggleGroupCallPanel();
|
||||||
openChat({
|
openChat({
|
||||||
id,
|
id,
|
||||||
});
|
});
|
||||||
closeDropdown();
|
onClose();
|
||||||
}, [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<HTMLInputElement>) => {
|
const handleChangeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = Number(e.target.value);
|
const value = Number(e.target.value);
|
||||||
setLocalVolume(value);
|
handleSetLocalVolume(value);
|
||||||
|
|
||||||
runThrottled(() => {
|
runThrottled(() => {
|
||||||
if (value === VOLUME_ZERO) {
|
if (value === VOLUME_ZERO) {
|
||||||
toggleGroupCallMute({
|
toggleGroupCallMute({
|
||||||
@ -147,11 +173,16 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<Menu
|
<Menu
|
||||||
isOpen={isDropdownOpen}
|
isOpen={isDropdownOpen}
|
||||||
positionX="right"
|
positionX={positionX}
|
||||||
autoClose
|
positionY={positionY}
|
||||||
style={buildStyle(anchor && `right: 1rem; top: ${anchor.y}px`)}
|
transformOriginX={transformOriginX}
|
||||||
onClose={closeDropdown}
|
transformOriginY={transformOriginY}
|
||||||
className="participant-menu"
|
style={style}
|
||||||
|
ref={menuRef}
|
||||||
|
withPortal
|
||||||
|
onClose={onClose}
|
||||||
|
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||||
|
className="participant-menu with-menu-transitions"
|
||||||
>
|
>
|
||||||
{!isSelf && !shouldRaiseHand && (
|
{!isSelf && !shouldRaiseHand && (
|
||||||
<div className="group">
|
<div className="group">
|
||||||
@ -173,6 +204,7 @@ const GroupCallParticipantMenu: FC<OwnProps & StateProps> = ({
|
|||||||
<div className="info">
|
<div className="info">
|
||||||
<AnimatedIcon
|
<AnimatedIcon
|
||||||
tgsUrl={LOCAL_TGS_URLS.Speaker}
|
tgsUrl={LOCAL_TGS_URLS.Speaker}
|
||||||
|
play={shouldPlay ? speakerIconPlaySegment.toString() : false}
|
||||||
playSegment={speakerIconPlaySegment}
|
playSegment={speakerIconPlaySegment}
|
||||||
size={SPEAKER_ICON_SIZE}
|
size={SPEAKER_ICON_SIZE}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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<string, GroupCallParticipant>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SelectedVideo = {
|
|
||||||
type: 'video' | 'presentation';
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const GroupCallParticipantStreams: FC<OwnProps & StateProps> = ({
|
|
||||||
participants,
|
|
||||||
onDoubleClick,
|
|
||||||
}) => {
|
|
||||||
const [selectedVideo, setSelectedVideo] = useState<SelectedVideo | undefined>(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 (
|
|
||||||
<div className="streams" onDoubleClick={onDoubleClick}>
|
|
||||||
<div
|
|
||||||
className={buildClassName(
|
|
||||||
'videos',
|
|
||||||
shouldSpanLastVideo && 'span-last-video',
|
|
||||||
)}
|
|
||||||
style={`--column-count: ${selectedVideo ? 1 : columnCount}`}
|
|
||||||
>
|
|
||||||
{selectedVideo && (
|
|
||||||
<GroupCallParticipantVideo
|
|
||||||
key={selectedVideo.id}
|
|
||||||
isFullscreen
|
|
||||||
onClick={handleClickVideo}
|
|
||||||
participant={participants![selectedVideo.id]}
|
|
||||||
type={selectedVideo.type}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selectedVideo ? presentationParticipants.map((participant) => (
|
|
||||||
<GroupCallParticipantVideo
|
|
||||||
key={participant.id}
|
|
||||||
onClick={handleClickVideo}
|
|
||||||
participant={participant}
|
|
||||||
type="presentation"
|
|
||||||
/>
|
|
||||||
)) : undefined}
|
|
||||||
{!selectedVideo ? videoParticipants.map((participant) => (
|
|
||||||
<GroupCallParticipantVideo
|
|
||||||
key={participant.id}
|
|
||||||
onClick={handleClickVideo}
|
|
||||||
participant={participant}
|
|
||||||
type="video"
|
|
||||||
/>
|
|
||||||
)) : undefined}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(withGlobal<OwnProps>(
|
|
||||||
(global): StateProps => {
|
|
||||||
const { participants } = selectActiveGroupCall(global) || {};
|
|
||||||
return {
|
|
||||||
participants,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
)(GroupCallParticipantStreams));
|
|
||||||
150
src/components/calls/group/GroupCallParticipantVideo.module.scss
Normal file
150
src/components/calls/group/GroupCallParticipantVideo.module.scss
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,77 +1,314 @@
|
|||||||
import type { GroupCallParticipant as TypeGroupCallParticipant } from '../../../lib/secret-sauce';
|
import React, {
|
||||||
import { getUserStreams, THRESHOLD } from '../../../lib/secret-sauce';
|
memo, useCallback, useEffect, useMemo, useRef, useState,
|
||||||
import type { FC } from '../../../lib/teact/teact';
|
} from '../../../lib/teact/teact';
|
||||||
import React, { memo, useCallback } from '../../../lib/teact/teact';
|
|
||||||
import { withGlobal } from '../../../global';
|
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 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 buildClassName from '../../../util/buildClassName';
|
||||||
import { selectChat, selectUser } from '../../../global/selectors';
|
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 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 = {
|
type OwnProps = {
|
||||||
|
layout: VideoLayout;
|
||||||
|
setPinned: (participant?: VideoParticipant) => void;
|
||||||
|
pinnedVideo: VideoParticipant | undefined;
|
||||||
|
canPin: boolean;
|
||||||
participant: TypeGroupCallParticipant;
|
participant: TypeGroupCallParticipant;
|
||||||
type: 'video' | 'presentation';
|
className?: string;
|
||||||
onClick?: (id: string, type: 'video' | 'presentation') => void;
|
|
||||||
isFullscreen?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateProps = {
|
type StateProps = {
|
||||||
user?: ApiUser;
|
user?: ApiUser;
|
||||||
chat?: ApiChat;
|
chat?: ApiChat;
|
||||||
currentUserId?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
||||||
type,
|
layout,
|
||||||
onClick,
|
pinnedVideo,
|
||||||
|
setPinned,
|
||||||
|
canPin,
|
||||||
|
className,
|
||||||
|
participant,
|
||||||
user,
|
user,
|
||||||
chat,
|
chat,
|
||||||
isActive,
|
|
||||||
isFullscreen,
|
|
||||||
}) => {
|
}) => {
|
||||||
const lang = useLang();
|
const lang = useLang();
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
// eslint-disable-next-line no-null/no-null
|
||||||
if (onClick) {
|
const thumbnailRef = useRef<HTMLCanvasElement>(null);
|
||||||
onClick(user?.id || chat!.id, type);
|
// eslint-disable-next-line no-null/no-null
|
||||||
}
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
}, [chat, onClick, type, user?.id]);
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const videoFallbackRef = useRef<HTMLCanvasElement>(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<VideoLayout>();
|
||||||
|
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 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<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line no-null/no-null
|
||||||
|
const menuRef = useRef<HTMLDivElement>(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={buildClassName('GroupCallParticipantVideo', isActive && 'active')}
|
className={buildClassName(
|
||||||
onClick={handleClick}
|
styles.wrapper,
|
||||||
|
(isHidden || isRemoved) && styles.hidden,
|
||||||
|
noAnimate && styles.noAnimate,
|
||||||
|
className,
|
||||||
|
isPinned && styles.pinned,
|
||||||
|
)}
|
||||||
|
style={`--x: ${currentX}px; --y: ${currentY}px; --width: ${currentWidth}px; --height: ${currentHeight}px;`}
|
||||||
|
ref={ref}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
onDoubleClick={canPin ? handleClickPin : undefined}
|
||||||
>
|
>
|
||||||
{isFullscreen && (
|
<div
|
||||||
<button className="back-button">
|
className={buildClassName(
|
||||||
<i className="icon icon-arrow-left" />
|
styles.root,
|
||||||
{lang('Back')}
|
isSpeaking && styles.speaking,
|
||||||
</button>
|
)}
|
||||||
)}
|
>
|
||||||
<Avatar user={user} chat={chat} className="thumbnail-avatar" />
|
{stream && (
|
||||||
{!GROUP_CALL_THUMB_VIDEO_DISABLED && (
|
<video
|
||||||
<div className="thumbnail-wrapper">
|
className={buildClassName(styles.video, shouldFlipVideo && styles.flipped)}
|
||||||
<video className="thumbnail" muted autoPlay playsInline srcObject={streams?.[type]} />
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
srcObject={stream}
|
||||||
|
ref={videoRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<canvas
|
||||||
|
className={buildClassName(styles.videoFallback, shouldFlipVideo && styles.flipped)}
|
||||||
|
ref={videoFallbackRef}
|
||||||
|
/>
|
||||||
|
<div className={styles.thumbnailWrapper}>
|
||||||
|
<canvas
|
||||||
|
className={buildClassName(styles.thumbnail, shouldFlipVideo && styles.flipped)}
|
||||||
|
ref={thumbnailRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canPin && (
|
||||||
|
<Button
|
||||||
|
round
|
||||||
|
size="smaller"
|
||||||
|
ripple
|
||||||
|
color="translucent"
|
||||||
|
className={styles.pinButton}
|
||||||
|
ariaLabel={lang(isPinned ? 'lng_group_call_context_unpin_camera' : 'lng_group_call_context_pin_camera')}
|
||||||
|
onClick={handleClickPin}
|
||||||
|
>
|
||||||
|
<i className={buildClassName('icon', isPinned ? 'icon-unpin' : 'icon-pin')} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className={styles.bottomPanel}>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<FullNameTitle peer={user || chat!} className={styles.name} />
|
||||||
|
<div className={styles.status}>{status}</div>
|
||||||
|
</div>
|
||||||
|
<OutlinedMicrophoneIcon participant={participant} className={styles.icon} noColor />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<video className="video" muted autoPlay playsInline srcObject={streams?.[type]} />
|
|
||||||
<div className="info">
|
|
||||||
<i className="icon icon-microphone-alt" />
|
|
||||||
<span className="name">{user?.firstName || chat?.title}</span>
|
|
||||||
{type === 'presentation' && <i className="icon last-icon icon-active-sessions" />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GroupCallParticipantMenu
|
||||||
|
participant={participant}
|
||||||
|
isDropdownOpen={isContextMenuOpen}
|
||||||
|
positionX={positionX}
|
||||||
|
positionY={positionY}
|
||||||
|
transformOriginX={transformOriginX}
|
||||||
|
transformOriginY={transformOriginY}
|
||||||
|
style={menuStyle}
|
||||||
|
onClose={handleContextMenuClose}
|
||||||
|
onCloseAnimationEnd={handleContextMenuHide}
|
||||||
|
menuRef={menuRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -79,10 +316,8 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
|||||||
export default memo(withGlobal<OwnProps>(
|
export default memo(withGlobal<OwnProps>(
|
||||||
(global, { participant }): StateProps => {
|
(global, { participant }): StateProps => {
|
||||||
return {
|
return {
|
||||||
currentUserId: global.currentUserId,
|
|
||||||
user: participant.isUser ? selectUser(global, participant.id) : undefined,
|
user: participant.isUser ? selectUser(global, participant.id) : undefined,
|
||||||
chat: !participant.isUser ? selectChat(global, participant.id) : undefined,
|
chat: !participant.isUser ? selectChat(global, participant.id) : undefined,
|
||||||
isActive: (participant.amplitude || 0) > THRESHOLD,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
)(GroupCallParticipantVideo));
|
)(GroupCallParticipantVideo));
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
.info {
|
.info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
line-height: 1.125rem;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@ -58,15 +59,16 @@
|
|||||||
|
|
||||||
.Avatar {
|
.Avatar {
|
||||||
margin: 0 0 0 -0.75rem;
|
margin: 0 0 0 -0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
width: 2rem;
|
width: 2rem !important;
|
||||||
height: 2rem;
|
height: 2rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
width: 2.25rem;
|
width: 2.25rem !important;
|
||||||
height: 2.25rem;
|
height: 2.25rem !important;
|
||||||
border: 0.125rem solid var(--color-background);
|
border: 0.125rem solid var(--color-background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/components/calls/group/MicrophoneButton.module.scss
Normal file
52
src/components/calls/group/MicrophoneButton.module.scss
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
background: var(--gradient-green);
|
||||||
|
|
||||||
|
&::before, &::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
transition: 0.25s ease-in-out opacity, 0.25s ease-in-out transform;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: var(--gradient-blue);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--gradient-purple);
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
transition: 0.25s ease-in-out opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinnerVisible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canUnmute::before {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mutedByAdmin::after {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
@ -1,58 +0,0 @@
|
|||||||
.MicrophoneButton {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
outline: none !important;
|
|
||||||
position: relative;
|
|
||||||
width: 6rem;
|
|
||||||
height: 6rem;
|
|
||||||
border: 0;
|
|
||||||
background: radial-gradient(100% 100% at 100% 0%, #00a0b9 0%, #33c659 55%, #33c659 100%);
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 2rem;
|
|
||||||
color: #fff;
|
|
||||||
transition: 0.25s ease-out filter;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
width: 8rem;
|
|
||||||
height: 8rem;
|
|
||||||
background: #64C166;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(10px);
|
|
||||||
opacity: 0.2;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
body.is-ios & {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
filter: brightness(0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.crossed {
|
|
||||||
background: radial-gradient(100% 100% at 100% 0%, #00AFFE 0%, #00AFFE 55%, #007FFF 100%);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
background: #00AFFE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.muted-by-admin {
|
|
||||||
background: radial-gradient(85.5% 103.5% at 87.5% 20.65%, #CE4D74 0%, #3D52DF 100%);
|
|
||||||
&::before {
|
|
||||||
background: #3D52DF;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-connecting, &.is-connecting:hover {
|
|
||||||
background: #222B34;
|
|
||||||
&::before {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import type { GroupCallConnectionState } from '../../../lib/secret-sauce';
|
import type { GroupCallConnectionState } from '../../../lib/secret-sauce';
|
||||||
import type { FC } from '../../../lib/teact/teact';
|
import type { FC } from '../../../lib/teact/teact';
|
||||||
import React, {
|
import React, {
|
||||||
memo, useEffect, useMemo, useRef, useState,
|
memo, useCallback, useEffect, useMemo, useRef, useState,
|
||||||
} from '../../../lib/teact/teact';
|
} from '../../../lib/teact/teact';
|
||||||
import { getActions, withGlobal } from '../../../global';
|
import { getActions, withGlobal } from '../../../global';
|
||||||
|
|
||||||
@ -13,11 +13,17 @@ import { selectActiveGroupCall, selectGroupCallParticipant } from '../../../glob
|
|||||||
import useLang from '../../../hooks/useLang';
|
import useLang from '../../../hooks/useLang';
|
||||||
|
|
||||||
import AnimatedIcon from '../../common/AnimatedIcon';
|
import AnimatedIcon from '../../common/AnimatedIcon';
|
||||||
|
import Button from '../../ui/Button';
|
||||||
|
import Spinner from '../../ui/Spinner';
|
||||||
|
|
||||||
import './MicrophoneButton.scss';
|
import styles from './MicrophoneButton.module.scss';
|
||||||
|
|
||||||
const CONNECTION_STATE_DEFAULT = 'discarded';
|
const CONNECTION_STATE_DEFAULT = 'discarded';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type StateProps = {
|
type StateProps = {
|
||||||
connectionState?: GroupCallConnectionState;
|
connectionState?: GroupCallConnectionState;
|
||||||
hasRequestedToSpeak: boolean;
|
hasRequestedToSpeak: boolean;
|
||||||
@ -28,13 +34,13 @@ type StateProps = {
|
|||||||
|
|
||||||
const REQUEST_TO_SPEAK_THROTTLE = 3000;
|
const REQUEST_TO_SPEAK_THROTTLE = 3000;
|
||||||
const HOLD_TO_SPEAK_TIME = 200;
|
const HOLD_TO_SPEAK_TIME = 200;
|
||||||
const ICON_SIZE = 48;
|
const ICON_SIZE = 36;
|
||||||
|
|
||||||
const MicrophoneButton: FC<StateProps> = ({
|
const MicrophoneButton: FC<OwnProps & StateProps> = ({
|
||||||
|
className,
|
||||||
noAudioStream,
|
noAudioStream,
|
||||||
canSelfUnmute,
|
canSelfUnmute,
|
||||||
isMuted,
|
isMuted,
|
||||||
hasRequestedToSpeak,
|
|
||||||
connectionState,
|
connectionState,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
@ -89,12 +95,12 @@ const MicrophoneButton: FC<StateProps> = ({
|
|||||||
|
|
||||||
const animatedIconName = isRequestingToSpeak ? 'HandFilled' : 'VoiceMini';
|
const animatedIconName = isRequestingToSpeak ? 'HandFilled' : 'VoiceMini';
|
||||||
|
|
||||||
const toggleMute = () => {
|
const toggleMute = useCallback(() => {
|
||||||
vibrateShort();
|
vibrateShort();
|
||||||
toggleGroupCallMute();
|
toggleGroupCallMute();
|
||||||
};
|
}, [toggleGroupCallMute]);
|
||||||
|
|
||||||
const handleMouseDownMute = () => {
|
const handleMouseDownMute = useCallback(() => {
|
||||||
if (shouldRaiseHand) {
|
if (shouldRaiseHand) {
|
||||||
if (isRequestingToSpeak) return;
|
if (isRequestingToSpeak) return;
|
||||||
vibrateShort();
|
vibrateShort();
|
||||||
@ -114,47 +120,42 @@ const MicrophoneButton: FC<StateProps> = ({
|
|||||||
}
|
}
|
||||||
}, HOLD_TO_SPEAK_TIME);
|
}, HOLD_TO_SPEAK_TIME);
|
||||||
}
|
}
|
||||||
};
|
}, [isRequestingToSpeak, noAudioStream, requestToSpeak, shouldRaiseHand, toggleMute]);
|
||||||
|
|
||||||
const handleMouseUpMute = () => {
|
const handleMouseUpMute = useCallback(() => {
|
||||||
if (shouldRaiseHand) {
|
if (shouldRaiseHand) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toggleMute();
|
toggleMute();
|
||||||
muteMouseDownState.current = 'up';
|
muteMouseDownState.current = 'up';
|
||||||
};
|
}, [shouldRaiseHand, toggleMute]);
|
||||||
|
|
||||||
const buttonText = lang(
|
|
||||||
hasRequestedToSpeak ? 'VoipMutedTapedForSpeak' : (
|
|
||||||
shouldRaiseHand ? 'VoipMutedByAdmin' : (
|
|
||||||
noAudioStream ? 'VoipUnmute' : 'VoipTapToMute'
|
|
||||||
)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="button-wrapper microphone-wrapper">
|
<Button
|
||||||
<button
|
round
|
||||||
className={buildClassName(
|
size="default"
|
||||||
'MicrophoneButton',
|
className={buildClassName(
|
||||||
noAudioStream && 'crossed',
|
styles.root,
|
||||||
canSelfUnmute && 'can-self-unmute',
|
!isConnecting && noAudioStream && styles.canUnmute,
|
||||||
isConnecting && 'is-connecting',
|
!isConnecting && shouldRaiseHand && styles.mutedByAdmin,
|
||||||
shouldRaiseHand && 'muted-by-admin',
|
className,
|
||||||
)}
|
)}
|
||||||
onMouseDown={handleMouseDownMute}
|
onMouseDown={handleMouseDownMute}
|
||||||
onMouseUp={handleMouseUpMute}
|
onMouseUp={handleMouseUpMute}
|
||||||
>
|
ariaLabel={lang(isMuted ? 'VoipUnmute' : 'VoipMute')}
|
||||||
<AnimatedIcon
|
disabled={isConnecting}
|
||||||
tgsUrl={LOCAL_TGS_URLS[animatedIconName]}
|
>
|
||||||
size={ICON_SIZE}
|
<AnimatedIcon
|
||||||
playSegment={playSegment}
|
tgsUrl={LOCAL_TGS_URLS[animatedIconName]}
|
||||||
/>
|
size={ICON_SIZE}
|
||||||
</button>
|
play={playSegment.toString()}
|
||||||
<div className="button-text">
|
playSegment={playSegment}
|
||||||
{buttonText}
|
className={styles.icon}
|
||||||
</div>
|
forceOnHeavyAnimation
|
||||||
</div>
|
forceInBackground
|
||||||
|
/>
|
||||||
|
<Spinner className={buildClassName(styles.spinner, isConnecting && styles.spinnerVisible)} color="white" />
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,11 +11,13 @@ import AnimatedIcon from '../../common/AnimatedIcon';
|
|||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
participant: GroupCallParticipant;
|
participant: GroupCallParticipant;
|
||||||
noColor?: boolean;
|
noColor?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OutlinedMicrophoneIcon: FC<OwnProps> = ({
|
const OutlinedMicrophoneIcon: FC<OwnProps> = ({
|
||||||
participant,
|
participant,
|
||||||
noColor,
|
noColor,
|
||||||
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { isMuted, isMutedByMe } = participant;
|
const { isMuted, isMutedByMe } = participant;
|
||||||
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
|
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
|
||||||
@ -51,21 +53,37 @@ const OutlinedMicrophoneIcon: FC<OwnProps> = ({
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [isMuted, shouldRaiseHand, isRaiseHand]);
|
}, [isMuted, shouldRaiseHand, isRaiseHand]);
|
||||||
|
|
||||||
const microphoneColor: string | undefined = useMemo(() => {
|
const microphoneColor: string = useMemo(() => {
|
||||||
return noColor ? '#ffffff' : (
|
if (noColor) {
|
||||||
isRaiseHand ? '#4da6e0'
|
return '#ffffff';
|
||||||
: (shouldRaiseHand || isMutedByMe ? '#ff706f' : (
|
}
|
||||||
isSpeaking ? '#57bc6c' : '#848d94'
|
|
||||||
))
|
if (isRaiseHand) {
|
||||||
);
|
return '#4da6e0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRaiseHand || isMutedByMe) {
|
||||||
|
return '#ff706f';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSpeaking) {
|
||||||
|
return '#57bc6c';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '#aaaaaa';
|
||||||
}, [noColor, isRaiseHand, shouldRaiseHand, isMutedByMe, isSpeaking]);
|
}, [noColor, isRaiseHand, shouldRaiseHand, isMutedByMe, isSpeaking]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedIcon
|
<AnimatedIcon
|
||||||
tgsUrl={LOCAL_TGS_URLS.VoiceOutlined}
|
tgsUrl={LOCAL_TGS_URLS.VoiceOutlined}
|
||||||
|
play={playSegment.toString()}
|
||||||
playSegment={playSegment}
|
playSegment={playSegment}
|
||||||
size={28}
|
size={28}
|
||||||
color={microphoneColor}
|
color={microphoneColor}
|
||||||
|
className={className}
|
||||||
|
forceOnHeavyAnimation
|
||||||
|
forceInBackground
|
||||||
|
nonInteractive
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
237
src/components/calls/group/hooks/useGroupCallVideoLayout.ts
Normal file
237
src/components/calls/group/hooks/useGroupCallVideoLayout.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import {
|
||||||
|
useEffect, useMemo, useState,
|
||||||
|
} from '../../../../lib/teact/teact';
|
||||||
|
import useResizeObserver from '../../../../hooks/useResizeObserver';
|
||||||
|
import usePrevious from '../../../../hooks/usePrevious';
|
||||||
|
import useLastCallback from '../../../../hooks/useLastCallback';
|
||||||
|
|
||||||
|
const PADDING_HORIZONTAL = 10;
|
||||||
|
const PADDING_VERTICAL = 8;
|
||||||
|
const PREFERRED_PANEL_VIDEO_HEIGHT = 240;
|
||||||
|
|
||||||
|
export type VideoLayout = {
|
||||||
|
participantId: string;
|
||||||
|
type: 'video' | 'screen';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
shouldRemount?: boolean;
|
||||||
|
isRemounted?: boolean;
|
||||||
|
noAnimate?: boolean;
|
||||||
|
isRemoved?: boolean;
|
||||||
|
orderKey: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VideoParticipant = {
|
||||||
|
id: string;
|
||||||
|
type: 'video' | 'screen';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function useGroupCallVideoLayout({
|
||||||
|
primaryContainerRef,
|
||||||
|
secondaryContainerRef,
|
||||||
|
videoParticipants,
|
||||||
|
isLandscapeLayout,
|
||||||
|
pinnedVideo,
|
||||||
|
}: {
|
||||||
|
primaryContainerRef: RefObject<HTMLDivElement>;
|
||||||
|
secondaryContainerRef: RefObject<HTMLDivElement>;
|
||||||
|
videoParticipants: VideoParticipant[];
|
||||||
|
isLandscapeLayout: boolean;
|
||||||
|
pinnedVideo: VideoParticipant | undefined;
|
||||||
|
}) {
|
||||||
|
const [videoLayout, setVideoLayout] = useState<VideoLayout[]>([]);
|
||||||
|
const [panelOffset, setPanelOffset] = useState(0);
|
||||||
|
const videosCount = videoParticipants.length;
|
||||||
|
const prevVideosCount = usePrevious(videosCount);
|
||||||
|
const prevVideoParticipants = usePrevious(videoParticipants);
|
||||||
|
const removedVideoParticipants = useMemo(() => {
|
||||||
|
return prevVideoParticipants?.filter(
|
||||||
|
({ id, type }) => !videoParticipants.some((p) => p.id === id && p.type === type),
|
||||||
|
);
|
||||||
|
}, [prevVideoParticipants, videoParticipants]);
|
||||||
|
|
||||||
|
const recalculateLayout = useLastCallback(() => {
|
||||||
|
const primaryContainer = primaryContainerRef.current;
|
||||||
|
const secondaryContainer = secondaryContainerRef.current;
|
||||||
|
if (!secondaryContainer) return;
|
||||||
|
|
||||||
|
const removed = prevVideosCount !== undefined && prevVideosCount > videosCount
|
||||||
|
? prevVideosCount - videosCount : 0;
|
||||||
|
|
||||||
|
const {
|
||||||
|
x: secondaryInitialX,
|
||||||
|
y: secondaryInitialY,
|
||||||
|
width: secondaryContainerWidth,
|
||||||
|
} = secondaryContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
const layout: VideoLayout[] = [];
|
||||||
|
if (pinnedVideo !== undefined || !primaryContainer || !isLandscapeLayout) {
|
||||||
|
const isRemounted = true;
|
||||||
|
let skip = false;
|
||||||
|
let pinnedSkipIndex = 0;
|
||||||
|
let pinnedPush: VideoLayout | undefined;
|
||||||
|
let participants = videoParticipants;
|
||||||
|
if (pinnedVideo && primaryContainer && isLandscapeLayout) {
|
||||||
|
pinnedSkipIndex = participants
|
||||||
|
.findIndex(({ id, type }) => id === pinnedVideo.id && type === pinnedVideo.type);
|
||||||
|
if (pinnedSkipIndex !== -1) {
|
||||||
|
const {
|
||||||
|
x: initialX,
|
||||||
|
y: initialY,
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
} = primaryContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
const { id: participantId, type } = pinnedVideo;
|
||||||
|
|
||||||
|
pinnedPush = {
|
||||||
|
x: initialX,
|
||||||
|
y: initialY,
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
participantId,
|
||||||
|
type,
|
||||||
|
orderKey: pinnedSkipIndex,
|
||||||
|
};
|
||||||
|
skip = true;
|
||||||
|
participants = participants
|
||||||
|
.filter(({ id, type: videoType }) => id !== participantId || videoType !== pinnedVideo.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secondaryVideosCounts = skip ? videosCount - 1 : videosCount;
|
||||||
|
|
||||||
|
const isFirstBig = secondaryVideosCounts % 2 === 1;
|
||||||
|
const columns = 2;
|
||||||
|
const rows = Math.ceil(secondaryVideosCounts / columns);
|
||||||
|
const smallWidth = (secondaryContainerWidth - (columns - 1) * PADDING_HORIZONTAL) / columns;
|
||||||
|
const heightTotal = Math.max(0, isFirstBig
|
||||||
|
? (PREFERRED_PANEL_VIDEO_HEIGHT + (rows - 1) * smallWidth + (rows - 1) * PADDING_VERTICAL)
|
||||||
|
: rows * smallWidth + (rows - 1) * PADDING_VERTICAL);
|
||||||
|
|
||||||
|
for (let i = 0; i < secondaryVideosCounts; i++) {
|
||||||
|
const isBig = isFirstBig && i === 0;
|
||||||
|
const width = isBig ? secondaryContainerWidth : smallWidth;
|
||||||
|
const height = isBig ? PREFERRED_PANEL_VIDEO_HEIGHT : smallWidth;
|
||||||
|
|
||||||
|
const realIndex = isFirstBig && i !== 0 ? i + 1 : i;
|
||||||
|
|
||||||
|
const x = (isRemounted ? 0 : secondaryInitialX) + (realIndex % columns) * (width + PADDING_HORIZONTAL);
|
||||||
|
const y = (isRemounted ? 0 : secondaryInitialY) + (isFirstBig && i !== 0 ? (
|
||||||
|
PREFERRED_PANEL_VIDEO_HEIGHT + PADDING_VERTICAL
|
||||||
|
+ (Math.floor(realIndex / columns) - 1) * (height + PADDING_VERTICAL)
|
||||||
|
) : (
|
||||||
|
Math.floor(realIndex / columns) * (height + PADDING_VERTICAL)
|
||||||
|
));
|
||||||
|
layout.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
shouldRemount: !isRemounted,
|
||||||
|
isRemounted,
|
||||||
|
noAnimate: true,
|
||||||
|
participantId: participants[i].id,
|
||||||
|
type: participants[i].type,
|
||||||
|
orderKey: i >= pinnedSkipIndex ? i + 1 : i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinnedPush) {
|
||||||
|
layout.splice(pinnedSkipIndex, 0, pinnedPush);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedVideoParticipants) {
|
||||||
|
for (let i = 0; i < removed; i++) {
|
||||||
|
layout.push({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
isRemounted,
|
||||||
|
isRemoved: true,
|
||||||
|
participantId: removedVideoParticipants[i].id,
|
||||||
|
type: removedVideoParticipants[i].type,
|
||||||
|
orderKey: i + videosCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPanelOffset(heightTotal);
|
||||||
|
setVideoLayout(layout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
x: initialX,
|
||||||
|
y: initialY,
|
||||||
|
width: containerWidth,
|
||||||
|
height: containerHeight,
|
||||||
|
} = primaryContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
const columns = calculateColumnsCount(videosCount);
|
||||||
|
const rows = Math.ceil(videosCount / columns);
|
||||||
|
const width = (containerWidth - (columns - 1) * PADDING_HORIZONTAL) / columns;
|
||||||
|
const height = (containerHeight - (rows - 1) * PADDING_VERTICAL) / rows;
|
||||||
|
|
||||||
|
for (let i = 0; i < videosCount; i++) {
|
||||||
|
const x = initialX + (i % columns) * (width + PADDING_HORIZONTAL);
|
||||||
|
const y = initialY + Math.floor(i / columns) * (height + PADDING_VERTICAL);
|
||||||
|
layout.push({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
participantId: videoParticipants[i].id,
|
||||||
|
type: videoParticipants[i].type,
|
||||||
|
orderKey: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removedVideoParticipants) {
|
||||||
|
for (let i = 0; i < removed; i++) {
|
||||||
|
layout.push({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
isRemoved: true,
|
||||||
|
participantId: removedVideoParticipants[i].id,
|
||||||
|
type: removedVideoParticipants[i].type,
|
||||||
|
orderKey: i + videosCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPanelOffset(0);
|
||||||
|
setVideoLayout(layout);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(recalculateLayout, [
|
||||||
|
recalculateLayout, videoParticipants, isLandscapeLayout, pinnedVideo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useResizeObserver(primaryContainerRef, recalculateLayout, !primaryContainerRef.current);
|
||||||
|
useResizeObserver(secondaryContainerRef, recalculateLayout, !secondaryContainerRef.current);
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoLayout, panelOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateColumnsCount(videosCount: number) {
|
||||||
|
if (videosCount >= 25) {
|
||||||
|
return 5;
|
||||||
|
} else if (videosCount >= 13) {
|
||||||
|
return 4;
|
||||||
|
} else if (videosCount >= 7) {
|
||||||
|
return 3;
|
||||||
|
} else if (videosCount >= 3) {
|
||||||
|
return 2;
|
||||||
|
} else {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ export type OwnProps = {
|
|||||||
color?: string;
|
color?: string;
|
||||||
isLowPriority?: boolean;
|
isLowPriority?: boolean;
|
||||||
forceOnHeavyAnimation?: boolean;
|
forceOnHeavyAnimation?: boolean;
|
||||||
|
forceInBackground?: boolean;
|
||||||
sharedCanvas?: HTMLCanvasElement;
|
sharedCanvas?: HTMLCanvasElement;
|
||||||
sharedCanvasCoords?: { x: number; y: number };
|
sharedCanvasCoords?: { x: number; y: number };
|
||||||
onClick?: NoneToVoidFunction;
|
onClick?: NoneToVoidFunction;
|
||||||
@ -65,6 +66,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
isLowPriority,
|
isLowPriority,
|
||||||
color,
|
color,
|
||||||
forceOnHeavyAnimation,
|
forceOnHeavyAnimation,
|
||||||
|
forceInBackground,
|
||||||
sharedCanvas,
|
sharedCanvas,
|
||||||
sharedCanvasCoords,
|
sharedCanvasCoords,
|
||||||
onClick,
|
onClick,
|
||||||
@ -87,7 +89,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
const shouldUseColorFilter = !sharedCanvas && color;
|
const shouldUseColorFilter = !sharedCanvas && color;
|
||||||
const colorFilter = useColorFilter(shouldUseColorFilter ? color : undefined);
|
const colorFilter = useColorFilter(shouldUseColorFilter ? color : undefined);
|
||||||
|
|
||||||
const playKey = play || playSegment;
|
const playKey = play || (play === false ? false : playSegment);
|
||||||
const playRef = useStateRef(play);
|
const playRef = useStateRef(play);
|
||||||
const playSegmentRef = useStateRef(playSegment);
|
const playSegmentRef = useStateRef(playSegment);
|
||||||
|
|
||||||
@ -177,13 +179,13 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
if (
|
if (
|
||||||
!animation
|
!animation
|
||||||
|| !(playRef.current || playSegmentRef.current)
|
|| !(playRef.current || playSegmentRef.current)
|
||||||
|| isFrozen(forceOnHeavyAnimation)
|
|| isFrozen(forceOnHeavyAnimation, forceInBackground)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playSegmentRef.current) {
|
if (playSegmentRef.current) {
|
||||||
animation.playSegment(playSegmentRef.current, viewId);
|
animation.playSegment(playSegmentRef.current, shouldRestart, viewId);
|
||||||
} else {
|
} else {
|
||||||
animation.play(shouldRestart, viewId);
|
animation.play(shouldRestart, viewId);
|
||||||
}
|
}
|
||||||
@ -217,13 +219,13 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (playKey) {
|
if (playKey) {
|
||||||
if (!isFrozen(forceOnHeavyAnimation)) {
|
if (!isFrozen(forceOnHeavyAnimation, forceInBackground)) {
|
||||||
playAnimation(noLoop);
|
playAnimation(noLoop);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pauseAnimation();
|
pauseAnimation();
|
||||||
}
|
}
|
||||||
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceOnHeavyAnimation]);
|
}, [animation, playKey, noLoop, playAnimation, pauseAnimation, forceOnHeavyAnimation, forceInBackground]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (animation) {
|
if (animation) {
|
||||||
@ -241,7 +243,7 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
// Pausing frame may not happen in background,
|
// Pausing frame may not happen in background,
|
||||||
// so we need to make sure it happens right after focusing,
|
// so we need to make sure it happens right after focusing,
|
||||||
// then we can play again.
|
// then we can play again.
|
||||||
useBackgroundMode(pauseAnimation, playAnimationOnRaf, !playKey);
|
useBackgroundMode(pauseAnimation, playAnimationOnRaf, !playKey || forceInBackground);
|
||||||
|
|
||||||
if (sharedCanvas) {
|
if (sharedCanvas) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -264,6 +266,8 @@ const AnimatedSticker: FC<OwnProps> = ({
|
|||||||
|
|
||||||
export default memo(AnimatedSticker);
|
export default memo(AnimatedSticker);
|
||||||
|
|
||||||
function isFrozen(forceOnHeavyAnimation = false) {
|
function isFrozen(forceOnHeavyAnimation = false, forceInBackground = false) {
|
||||||
return (!forceOnHeavyAnimation && isHeavyAnimating()) || isPriorityPlaybackActive() || isBackgroundModeActive();
|
return (!forceOnHeavyAnimation && isHeavyAnimating())
|
||||||
|
|| isPriorityPlaybackActive()
|
||||||
|
|| (!forceInBackground && isBackgroundModeActive());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export type OwnProps = {
|
|||||||
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onClick?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
onContextMenu?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
onContextMenu?: (e: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||||
onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
|
onMouseDown?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||||
|
onMouseUp?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||||
onMouseEnter?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
|
onMouseEnter?: (e: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||||
onMouseLeave?: NoneToVoidFunction;
|
onMouseLeave?: NoneToVoidFunction;
|
||||||
onFocus?: NoneToVoidFunction;
|
onFocus?: NoneToVoidFunction;
|
||||||
@ -67,6 +68,7 @@ const Button: FC<OwnProps> = ({
|
|||||||
onClick,
|
onClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
|
onMouseUp,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
onFocus,
|
onFocus,
|
||||||
@ -185,6 +187,7 @@ const Button: FC<OwnProps> = ({
|
|||||||
onClick={IS_TOUCH_ENV || noFastClick ? handleClick : undefined}
|
onClick={IS_TOUCH_ENV || noFastClick ? handleClick : undefined}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={onMouseUp}
|
||||||
onMouseEnter={onMouseEnter && !disabled ? onMouseEnter : undefined}
|
onMouseEnter={onMouseEnter && !disabled ? onMouseEnter : undefined}
|
||||||
onMouseLeave={onMouseLeave && !disabled ? onMouseLeave : undefined}
|
onMouseLeave={onMouseLeave && !disabled ? onMouseLeave : undefined}
|
||||||
onTransitionEnd={onTransitionEnd}
|
onTransitionEnd={onTransitionEnd}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import useLastCallback from '../../hooks/useLastCallback';
|
|||||||
|
|
||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
ref?: RefObject<HTMLDivElement>;
|
ref?: RefObject<HTMLDivElement>;
|
||||||
|
style?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
items?: any[];
|
items?: any[];
|
||||||
itemSelector?: string;
|
itemSelector?: string;
|
||||||
@ -41,6 +42,7 @@ const DEFAULT_SENSITIVE_AREA = 800;
|
|||||||
|
|
||||||
const InfiniteScroll: FC<OwnProps> = ({
|
const InfiniteScroll: FC<OwnProps> = ({
|
||||||
ref,
|
ref,
|
||||||
|
style,
|
||||||
className,
|
className,
|
||||||
items,
|
items,
|
||||||
itemSelector = DEFAULT_LIST_SELECTOR,
|
itemSelector = DEFAULT_LIST_SELECTOR,
|
||||||
@ -238,6 +240,7 @@ const InfiniteScroll: FC<OwnProps> = ({
|
|||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
{beforeChildren}
|
{beforeChildren}
|
||||||
{withAbsolutePositioning && items?.length ? (
|
{withAbsolutePositioning && items?.length ? (
|
||||||
|
|||||||
@ -62,6 +62,7 @@ interface OwnProps {
|
|||||||
href?: string;
|
href?: string;
|
||||||
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
onClick?: (e: React.MouseEvent<HTMLElement>, arg?: any) => void;
|
onClick?: (e: React.MouseEvent<HTMLElement>, arg?: any) => void;
|
||||||
|
onContextMenu?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||||
clickArg?: any;
|
clickArg?: any;
|
||||||
onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void;
|
onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||||
@ -92,6 +93,7 @@ const ListItem: FC<OwnProps> = ({
|
|||||||
href,
|
href,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onClick,
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
clickArg,
|
clickArg,
|
||||||
onSecondaryIconClick,
|
onSecondaryIconClick,
|
||||||
onDragEnter,
|
onDragEnter,
|
||||||
@ -225,7 +227,7 @@ const ListItem: FC<OwnProps> = ({
|
|||||||
tabIndex={!isStatic ? 0 : undefined}
|
tabIndex={!isStatic ? 0 : undefined}
|
||||||
onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : handleClickEvent}
|
onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : handleClickEvent}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
onContextMenu={(!inactive && contextActions) ? handleContextMenu : undefined}
|
onContextMenu={onContextMenu || ((!inactive && contextActions) ? handleContextMenu : undefined)}
|
||||||
>
|
>
|
||||||
{leftElement}
|
{leftElement}
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|||||||
@ -262,7 +262,13 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
|
|||||||
chatId, id, accessHash, inviteHash, tabId = getCurrentTabId(),
|
chatId, id, accessHash, inviteHash, tabId = getCurrentTabId(),
|
||||||
} = payload;
|
} = payload;
|
||||||
|
|
||||||
if (!ARE_CALLS_SUPPORTED) return;
|
if (!ARE_CALLS_SUPPORTED) {
|
||||||
|
actions.showNotification({
|
||||||
|
message: "Sorry, your browser doesn't support group calls",
|
||||||
|
tabId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (global.phoneCall) {
|
if (global.phoneCall) {
|
||||||
actions.toggleGroupCallPanel({ tabId });
|
actions.toggleGroupCallPanel({ tabId });
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { GlobalState } from '../types';
|
import type { GlobalState } from '../types';
|
||||||
import { selectChat, selectChatFullInfo } from './chats';
|
import { selectChat, selectChatFullInfo } from './chats';
|
||||||
import { isChatBasicGroup } from '../helpers';
|
import { getMainUsername, isChatBasicGroup } from '../helpers';
|
||||||
import { selectUser } from './users';
|
import { selectUser } from './users';
|
||||||
|
|
||||||
export function selectChatGroupCall<T extends GlobalState>(global: T, chatId: string) {
|
export function selectChatGroupCall<T extends GlobalState>(global: T, chatId: string) {
|
||||||
@ -49,3 +49,24 @@ export function selectPhoneCallUser<T extends GlobalState>(global: T) {
|
|||||||
const id = phoneCall.adminId === currentUserId ? phoneCall.participantId : phoneCall.adminId;
|
const id = phoneCall.adminId === currentUserId ? phoneCall.participantId : phoneCall.adminId;
|
||||||
return selectUser(global, id);
|
return selectUser(global, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectCanInviteToActiveGroupCall<T extends GlobalState>(global: T) {
|
||||||
|
const groupCall = selectActiveGroupCall(global);
|
||||||
|
|
||||||
|
if (!groupCall || !groupCall.chatId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chat = selectChat(global, groupCall.chatId);
|
||||||
|
if (!chat) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPublicUsername = Boolean(getMainUsername(chat));
|
||||||
|
if (hasPublicUsername) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteLink = selectChatFullInfo(global, chat.id)?.inviteLink;
|
||||||
|
return Boolean(inviteLink);
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import type { Scheduler } from '../util/schedulers';
|
|||||||
import {
|
import {
|
||||||
throttle, debounce, throttleWith,
|
throttle, debounce, throttleWith,
|
||||||
} from '../util/schedulers';
|
} from '../util/schedulers';
|
||||||
import useEffectOnce from './useEffectOnce';
|
|
||||||
import useHeavyAnimationCheck from './useHeavyAnimationCheck';
|
import useHeavyAnimationCheck from './useHeavyAnimationCheck';
|
||||||
import useLastCallback from './useLastCallback';
|
import useLastCallback from './useLastCallback';
|
||||||
|
|
||||||
@ -171,10 +170,10 @@ export function useIntersectionObserver({
|
|||||||
export function useOnIntersect(
|
export function useOnIntersect(
|
||||||
targetRef: RefObject<HTMLDivElement>, observe?: ObserveFn, callback?: TargetCallback,
|
targetRef: RefObject<HTMLDivElement>, observe?: ObserveFn, callback?: TargetCallback,
|
||||||
) {
|
) {
|
||||||
useEffectOnce(() => {
|
useEffect(() => {
|
||||||
return observe ? observe(targetRef.current!, callback) : undefined;
|
return observe ? observe(targetRef.current!, callback) : undefined;
|
||||||
// Arguments should never change
|
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
|
||||||
});
|
}, [observe]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIsIntersecting(
|
export function useIsIntersecting(
|
||||||
|
|||||||
@ -191,12 +191,16 @@ class RLottie {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
playSegment([startFrameIndex, stopFrameIndex]: [number, number], viewId?: string) {
|
playSegment([startFrameIndex, stopFrameIndex]: [number, number], forceRestart = false, viewId?: string) {
|
||||||
if (viewId) {
|
if (viewId) {
|
||||||
this.views.get(viewId)!.isPaused = false;
|
this.views.get(viewId)!.isPaused = false;
|
||||||
}
|
}
|
||||||
this.approxFrameIndex = Math.floor(startFrameIndex / this.reduceFactor);
|
|
||||||
|
const frameIndex = Math.round(this.approxFrameIndex);
|
||||||
this.stopFrameIndex = Math.floor(stopFrameIndex / this.reduceFactor);
|
this.stopFrameIndex = Math.floor(stopFrameIndex / this.reduceFactor);
|
||||||
|
if (frameIndex !== stopFrameIndex || forceRestart) {
|
||||||
|
this.approxFrameIndex = Math.floor(startFrameIndex / this.reduceFactor);
|
||||||
|
}
|
||||||
this.direction = startFrameIndex < stopFrameIndex ? 1 : -1;
|
this.direction = startFrameIndex < stopFrameIndex ? 1 : -1;
|
||||||
|
|
||||||
this.doPlay();
|
this.doPlay();
|
||||||
|
|||||||
@ -380,7 +380,6 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
|
|||||||
|
|
||||||
const newEndpoints: string[] = [];
|
const newEndpoints: string[] = [];
|
||||||
updatedParticipants.forEach((participant) => {
|
updatedParticipants.forEach((participant) => {
|
||||||
console.log('handleUpdateGroupCallParticipants', participant);
|
|
||||||
if (participant.isSelf) {
|
if (participant.isSelf) {
|
||||||
if (participant.isMuted && !participant.canSelfUnmute) {
|
if (participant.isMuted && !participant.canSelfUnmute) {
|
||||||
// Muted by admin
|
// Muted by admin
|
||||||
@ -482,7 +481,6 @@ export async function handleUpdateGroupCallParticipants(updatedParticipants: Gro
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sdp = buildSdp(conference as Conference);
|
const sdp = buildSdp(conference as Conference);
|
||||||
console.log('build sdp!', sdp);
|
|
||||||
await connection.setRemoteDescription({
|
await connection.setRemoteDescription({
|
||||||
type: 'offer',
|
type: 'offer',
|
||||||
sdp,
|
sdp,
|
||||||
@ -567,8 +565,6 @@ export async function handleUpdateGroupCallConnection(data: GroupCallConnectionD
|
|||||||
...(!isPresentation ? { conference: newConference } : { screenshareConference: newConference }),
|
...(!isPresentation ? { conference: newConference } : { screenshareConference: newConference }),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.warn('update remote description', newConference, buildSdp(newConference, true, isPresentation));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await connection.setRemoteDescription({
|
await connection.setRemoteDescription({
|
||||||
type: 'answer',
|
type: 'answer',
|
||||||
@ -683,7 +679,6 @@ function initializeConnection(
|
|||||||
if (!isPresentation) {
|
if (!isPresentation) {
|
||||||
connection.oniceconnectionstatechange = () => {
|
connection.oniceconnectionstatechange = () => {
|
||||||
const connectionState = connection.iceConnectionState;
|
const connectionState = connection.iceConnectionState;
|
||||||
console.log('ice', connectionState);
|
|
||||||
if (connectionState === 'connected' || connectionState === 'completed') {
|
if (connectionState === 'connected' || connectionState === 'completed') {
|
||||||
updateConnectionState('connected');
|
updateConnectionState('connected');
|
||||||
} else if (connectionState === 'checking' || connectionState === 'new') {
|
} else if (connectionState === 'checking' || connectionState === 'new') {
|
||||||
@ -707,8 +702,6 @@ function initializeConnection(
|
|||||||
offerToReceiveAudio: !isPresentation,
|
offerToReceiveAudio: !isPresentation,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('created offer!', offer);
|
|
||||||
|
|
||||||
await connection.setLocalDescription(offer);
|
await connection.setLocalDescription(offer);
|
||||||
|
|
||||||
if (!offer.sdp) {
|
if (!offer.sdp) {
|
||||||
@ -716,7 +709,6 @@ function initializeConnection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sdp = parseSdp(offer);
|
const sdp = parseSdp(offer);
|
||||||
console.log('parsed sdp', sdp);
|
|
||||||
const audioSsrc: Ssrc | undefined = !isPresentation ? {
|
const audioSsrc: Ssrc | undefined = !isPresentation ? {
|
||||||
userId: '',
|
userId: '',
|
||||||
sourceGroups: [
|
sourceGroups: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -230,6 +230,7 @@ $color-message-reaction-own-hover: #b5e0a4;
|
|||||||
--z-header-menu: 990;
|
--z-header-menu: 990;
|
||||||
--z-header-menu-backdrop: 980;
|
--z-header-menu-backdrop: 980;
|
||||||
--z-modal: 1510;
|
--z-modal: 1510;
|
||||||
|
--z-modal-menu: 1600;
|
||||||
--z-media-viewer: 1500;
|
--z-media-viewer: 1500;
|
||||||
--z-video-player-controls: 3;
|
--z-video-player-controls: 3;
|
||||||
--z-drop-area: 55;
|
--z-drop-area: 55;
|
||||||
|
|||||||
@ -49,6 +49,12 @@
|
|||||||
.icon-volume-3:before {
|
.icon-volume-3:before {
|
||||||
content: "\e991";
|
content: "\e991";
|
||||||
}
|
}
|
||||||
|
.icon-settings-filled:before {
|
||||||
|
content: "\e9c1";
|
||||||
|
}
|
||||||
|
.icon-share-screen-stop:before {
|
||||||
|
content: "\e9c2";
|
||||||
|
}
|
||||||
.icon-user-online:before {
|
.icon-user-online:before {
|
||||||
content: "\e9c0";
|
content: "\e9c0";
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user