@@ -262,8 +262,22 @@ function MediaTrack({ track, muted = false, className = '' }) {
function RemoteAudioTrack ( { track , volume } ) {
const audioRef = useRef ( null ) ;
const gainRef = useRef ( null ) ;
useEffect ( ( ) => {
if ( ! track || ! audioRef . current ) return undefined ;
track . attach ( audioRef . current ) ;
return ( ) => {
track . detach ( audioRef . current ) ;
} ;
} , [ track ] ) ;
useEffect ( ( ) => {
if ( ! audioRef . current ) return ;
audioRef . current . volume = Math . min ( 1 , Math . max ( 0 , volume ) ) ;
} , [ volume ] ) ;
useEffect ( ( ) => {
if ( ! track ? . mediaStreamTrack ) return undefined ;
const AudioContextClass = window . AudioContext || window . webkitAudioContext ;
@@ -272,10 +286,10 @@ function RemoteAudioTrack({ track, volume }) {
const stream = new MediaStream ( [ track . mediaStreamTrack ] ) ;
const source = context . createMediaStreamSource ( stream ) ;
const gain = context . createGain ( ) ;
gain . gain . value = volume ;
gain . gain . value = Math . max ( 0 , volume - 1 ) ;
source . connect ( gain ) ;
gain . connect ( context . destination ) ;
gainRef . current = gain ;
gainRef . current = { context , gain } ;
context . resume ( ) . catch ( ( ) => { } ) ;
return ( ) => {
@@ -287,14 +301,14 @@ function RemoteAudioTrack({ track, volume }) {
} , [ track ] ) ;
useEffect ( ( ) => {
if ( gainRef . current ) gainRef . current . gain . value = volume ;
if ( gainRef . current ) gainRef . current . gain . gain . value = Math . max ( 0 , volume - 1 ) ;
} , [ volume ] ) ;
return null ;
return < audio ref = { audioRef } autoPlay playsInline className = 'hidden-audio' > < / audio > ;
}
function VideoTile ( { item , active , volume , onVolumeChange } ) {
function VideoTile ( { item , active , volume , volumeMenuOpen , onScreenFullscreen , onToggleVolumeMenu , onVolumeChange } ) {
const camera = item . tracks . find ( ( entry ) => entry . kind === 'video' && entry . source !== Track . Source . ScreenShare ) ;
const screen = item . tracks . find ( ( entry ) => entry . kind === 'video' && entry . source === Track . Source . ScreenShare ) ;
const audio = item . tracks . filter ( ( entry ) => entry . kind === 'audio' ) ;
@@ -302,24 +316,45 @@ function VideoTile({ item, active, volume, onVolumeChange }) {
const volumePercent = Math . round ( volume * 100 ) ;
return (
< div className = { ` video-tile ${ active ? ' speaking' : '' } ${ screen ? ' has-screen' : '' } ` } >
< div
className = { ` video-tile ${ active ? ' speaking' : '' } ${ screen ? ' has-screen' : '' } ` }
onContextMenu = { ( event ) => {
if ( item . isLocal ) return ;
event . preventDefault ( ) ;
onToggleVolumeMenu ( item . identity ) ;
} }
>
< div className = 'video-label-row' >
< span > { item . name } < / span >
< span className = 'muted' > { item . isLocal ? 'ты' : '' } < / span >
< div className = 'tile-actions' >
< span className = 'muted' > { item . isLocal ? 'ты' : '' } < / span >
{ ! item . isLocal ? (
< button className = 'tile-action-btn' type = 'button' onClick = { ( ) => onToggleVolumeMenu ( item . identity ) } aria - label = { ` Настройки участника ${ item . name } ` } >
⚙
< / button >
) : null }
{ screen ? (
< button className = 'tile-action-btn' type = 'button' onClick = { ( event ) => onScreenFullscreen ( event . currentTarget ) } aria - label = 'Развернуть демонстрацию' >
⛶
< / button >
) : null }
< / div >
< / div >
{ ! item . isLocal ? (
< label className = 'participant-volume' >
< span > Громкость : { volumePercent } % < / span >
< input
aria - label = { ` Громкость участника ${ item . name } ` }
max = '2'
min = '0 '
onChange = { ( event ) => onVolumeChange ( item . identity , Number ( event . target . value ) ) }
step = '0.05'
type = 'range '
value = { volume }
/ >
< / label >
{ ! item . isLocal && volumeMenuOpen ? (
< div className = 'participant-volume-popover ' >
< label className = 'participant-volume' >
< span > Громкость : { volumePercent } % < / span >
< input
aria - label = { ` Громкость участника ${ item . name } ` }
max = '2 '
min = '0'
onChange = { ( event ) => onVolumeChange ( item . identity , Number ( event . target . value ) ) }
step = '0.05 '
type = 'range'
value = { volume }
/ >
< / label >
< / div >
) : null }
{ screen ? (
< div className = 'screen-slot' >
@@ -346,6 +381,7 @@ function CallPage({ roomName, showToast }) {
const [ room , setRoom ] = useState ( null ) ;
const [ participants , setParticipants ] = useState ( [ ] ) ;
const [ participantVolumes , setParticipantVolumes ] = useState ( { } ) ;
const [ volumeMenuIdentity , setVolumeMenuIdentity ] = useState ( '' ) ;
const [ activeSpeakers , setActiveSpeakers ] = useState ( [ ] ) ;
const [ messages , setMessages ] = useState ( [ ] ) ;
const [ chatText , setChatText ] = useState ( '' ) ;
@@ -353,6 +389,7 @@ function CallPage({ roomName, showToast }) {
const [ elapsed , setElapsed ] = useState ( 0 ) ;
const previewRef = useRef ( null ) ;
const previewStreamRef = useRef ( null ) ;
const meetingCardRef = useRef ( null ) ;
const fileRef = useRef ( null ) ;
const inviteToken = useMemo ( getInviteToken , [ ] ) ;
@@ -471,6 +508,43 @@ function CallPage({ roomName, showToast }) {
} ) ) ;
} , [ ] ) ;
const toggleParticipantVolumeMenu = useCallback ( ( identity ) => {
setVolumeMenuIdentity ( ( current ) => current === identity ? '' : identity ) ;
} , [ ] ) ;
const fullscreenElement = ( ) => document . fullscreenElement || document . webkitFullscreenElement ;
const requestFullscreen = async ( element ) => {
if ( ! element ) return ;
if ( element . requestFullscreen ) {
await element . requestFullscreen ( ) ;
return ;
}
if ( element . webkitRequestFullscreen ) element . webkitRequestFullscreen ( ) ;
} ;
const exitFullscreen = async ( ) => {
if ( document . exitFullscreen ) {
await document . exitFullscreen ( ) ;
return ;
}
if ( document . webkitExitFullscreen ) document . webkitExitFullscreen ( ) ;
} ;
const toggleCallFullscreen = async ( ) => {
if ( fullscreenElement ( ) ) {
await exitFullscreen ( ) ;
return ;
}
await requestFullscreen ( meetingCardRef . current ) ;
} ;
const openScreenFullscreen = async ( button ) => {
const tile = button . closest ( '.video-tile' ) ;
const target = tile ? . querySelector ( '.screen-slot video' ) || tile ? . querySelector ( '.screen-slot' ) ;
await requestFullscreen ( target ) ;
} ;
const joinCall = async ( ) => {
const name = displayName . trim ( ) ;
if ( ! name ) {
@@ -708,59 +782,65 @@ function CallPage({ roomName, showToast }) {
< / section >
) : (
< >
< section className = 'card meeting-card ' >
< div className = 'meeting-topbar' >
< div >
< div className = 'meeting-title' > Созвон < / div >
< h1 className = 'meeting-room-name' > { call . room _title } < / h1 >
< p className = 'muted' > В комнате : { participants . length } < / p >
< / div >
< div className = 'meeting-controls' >
< button className = { ` icon-btn ${ micEnabled ? '' : 'is-off' } ` } type = 'button' onClick = { toggleMic } aria - label = { micEnabled ? 'Выключить микрофон' : 'Включить микрофон' } >
< img className = 'control-icon' src = { micEnabled ? ICONS . micOn : ICONS . micOff } alt = '' / >
< / button >
< button className = { ` icon-btn ${ cameraEnabled ? '' : 'is-off' } ` } type = 'button' onClick = { toggleCamera } aria - label = { cameraEnabled ? 'Выключить камеру' : 'Включить камеру' } >
< img className = 'control-icon' src = { cameraEnabled ? ICONS . cameraOn : ICONS . cameraOff } alt = '' / >
< / button >
< button className = 'icon-btn' type = 'button' onClick = { toggleScreen } > Screen < / button >
< button className = 'btn secondary' type = 'button' onClick = { copyInvite } > Ссылка < / button >
< div className = 'meeting-timer' > { time } < / div >
< / div >
< / div >
< div className = 'videos' >
{ participants . map ( ( item ) => (
< VideoTile
active = { activeSpeakers . includes ( item . identity ) }
item = { item }
key = { item . identity }
onVolumeChange = { updateParticipantVolume }
volume = { participantVolumes [ item . identity ] ? ? 1 }
/ >
) ) }
< / div >
< / section >
< aside className = 'card chat-card' >
< h2 > Чат комнаты < / h2 >
< div className = 'chat-messages' >
{ messages . map ( ( message ) => (
< div className = { ` chat-message ${ message . own ? ' own' : '' } ` } key = { message . id } >
< div className = 'chat-author' > { message . author } < / div >
{ message . attachment ? (
< a className = 'file-link' href = { ` ${ message . attachment . download _url } ?invite_token= ${ encodeURIComponent ( inviteToken ) } ` } target = '_blank' rel = 'noreferrer' > { message . attachment . file _name } < / a >
) : < div className = 'chat-text' > { message . text } < / div > }
< div className = 'call-layout ' >
< section className = 'card meeting-card' ref = { meetingCardRef } >
< div className = 'meeting-topbar' >
< div >
< div className = 'meeting-title' > Созвон < / div >
< h1 className = 'meeting-room-name' > { call . room _title } < / h1 >
< p className = 'muted' > В комнате : { participants . length } < / p >
< / div >
) ) }
< / div >
< div className = 'chat-form' >
< input value = { chatText } onChange = { ( event ) => setChatText ( event . target . value ) } onKeyDown = { ( event ) => event . key === 'Enter' ? sendChat ( ) : null } placeholder = 'Напиши сообщение...' maxLength = '1000' / >
< input className = 'hidden-file-input' ref = { fileRef } type = 'file ' onChange = { ( event ) => sendFile ( event . target . files ? . [ 0 ] ) } / >
< button className = 'i con-btn attachment-btn' type = 'button' onClick = { ( ) => fileRef . current ? . click ( ) } aria - label = 'Прикрепить файл' >
< img className = 'control-icon' src = { ICONS . attachment } alt = '' / >
< / button >
< button className = 'btn prim ary' type = 'button' onClick = { sendChat } > Отправить < / button >
< / div >
< / aside >
< div className = 'meeting-controls' >
< button className = { ` icon-btn ${ micEnabled ? '' : 'is-off' } ` } type = 'button' onClick = { toggleMic } aria - label = { micEnabled ? 'Выключить микрофон' : 'Включить микрофон' } >
< img className = 'control-icon' src = { micEnabled ? ICONS . micOn : ICONS . micOff } alt = '' / >
< / button >
< button className = { ` icon-btn ${ cameraEnabled ? '' : 'is-off' } ` } type = 'button ' onClick = { toggleCamera } aria - label = { cameraEnabled ? 'Выключить камеру' : 'Включить камеру' } >
< img className = 'control-icon' src = { cameraEnabled ? ICONS . cameraOn : ICONS . cameraOff } alt = '' / >
< / button >
< button className = 'icon-btn' type = 'button' onClick = { toggleScreen } aria - label = 'Демонстрация экрана' > Screen < / button >
< button className = 'btn second ary' type = 'button' onClick = { toggleCallFullscreen } > В о весь экран < / button >
< button className = 'btn secondary' type = 'button' onClick = { copyInvite } > Ссылка < / button >
< div className = 'meeting-timer' > { time } < / div >
< / div >
< / div >
< div className = 'videos' >
{ participants . map ( ( item ) => (
< VideoTile
active = { activeSpeakers . includes ( item . identity ) }
item = { item }
key = { item . identity }
onScreenFullscreen = { openScreenFullscreen }
onToggleVolumeMenu = { toggleParticipantVolumeMenu }
onVolumeChange = { updateParticipantVolume }
volume = { participantVolumes [ item . identity ] ? ? 1 }
volumeMenuOpen = { volumeMenuIdentity === item . identity }
/ >
) ) }
< / div >
< / section >
< aside className = 'card chat-card' >
< h2 > Чат комнаты < / h2 >
< div className = 'chat-messages' >
{ messages . map ( ( message ) => (
< div className = { ` chat-message ${ message . own ? ' own' : '' } ` } key = { message . id } >
< div className = 'chat-author' > { message . author } < / div >
{ message . attachment ? (
< a className = 'file-link' href = { ` ${ message . attachment . download _url } ?invite_token= ${ encodeURIComponent ( inviteToken ) } ` } target = '_blank' rel = 'noreferrer' > { message . attachment . file _name } < / a >
) : < div className = 'chat-text' > { message . text } < / div > }
< / div >
) ) }
< / div >
< div className = 'chat-form' >
< input value = { chatText } onChange = { ( event ) => setChatText ( event . target . value ) } onKeyDown = { ( event ) => event . key === 'Enter' ? sendChat ( ) : null } placeholder = 'Напиши сообщение...' maxLength = '1000' / >
< input className = 'hidden-file-input' ref = { fileRef } type = 'file' onChange = { ( event ) => sendFile ( event . target . files ? . [ 0 ] ) } / >
< button className = 'icon-btn attachment-btn' type = 'button' onClick = { ( ) => fileRef . current ? . click ( ) } aria - label = 'Прикрепить файл' >
< img className = 'control-icon' src = { ICONS . attachment } alt = '' / >
< / button >
< button className = 'btn primary' type = 'button' onClick = { sendChat } > Отправить < / button >
< / div >
< / aside >
< / div >
< div className = 'call-bottom-actions is-visible' >
< button className = 'btn secondary' type = 'button' onClick = { leaveCall } > Выйти < / button >