Office: refactor livekit logic (#9679)

This commit is contained in:
Anton Alexeyev
2025-08-19 02:24:02 +07:00
committed by GitHub
parent d2a5a2ef53
commit 74cfdbccf8
43 changed files with 1587 additions and 1579 deletions
+469 -505
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -267,7 +267,7 @@
"electron-store": "^8.2.0",
"electron-log": "^5.1.7",
"electron-updater": "^6.3.4",
"livekit-client": "^2.13.3",
"livekit-client": "^2.15.5",
"@hcengineering/server-backup": "^0.6.0",
"@hcengineering/communication-types": "^0.1.0",
"ws": "^8.18.2"
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "Sdílení...",
"Browser": "Prohlížeč",
"Monitor": "Monitor",
"Window": "Okno"
"Window": "Okno",
"MeetingEmptyTitle": "Schůzka ukončena?",
"MeetingEmptyMessage": "Všichni účastníci odešli. Během 1 minuty budete odpojeni. Chcete zůstat?",
"StayInRoom": "Zůstat v místnosti"
}
}
+4 -1
View File
@@ -88,6 +88,9 @@
"Sharing": "Teilen...",
"Browser": "Browser",
"Monitor": "Monitor",
"Window": "Fenster"
"Window": "Fenster",
"MeetingEmptyTitle": "Besprechung beendet?",
"MeetingEmptyMessage": "Alle Teilnehmer haben die Besprechung verlassen. Sie werden in 1 Minute automatisch getrennt. Möchten Sie bleiben?",
"StayInRoom": "Im Raum bleiben"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "Sharing...",
"Browser": "Browser",
"Monitor": "Monitor",
"Window": "Window"
"Window": "Window",
"MeetingEmptyTitle": "Meeting ended?",
"MeetingEmptyMessage": "All participants have left. You'll be disconnected in 1 minute. Would you like to stay?",
"StayInRoom": "Stay in room"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "Compartiendo...",
"Browser": "Navegador",
"Monitor": "Monitor",
"Window": "Ventana"
"Window": "Ventana",
"MeetingEmptyTitle": "¿Reunión finalizada?",
"MeetingEmptyMessage": "Todos los participantes han salido. Serás desconectado en 1 minuto. ¿Deseas permanecer?",
"StayInRoom": "Permanecer en sala"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "Partage...",
"Browser": "Navigateur",
"Monitor": "Moniteur",
"Window": "Fenêtre"
"Window": "Fenêtre",
"MeetingEmptyTitle": "Réunion terminée ?",
"MeetingEmptyMessage": "Tous les participants sont partis. Vous serez déconnecté dans 1 minute. Souhaitez-vous rester ?",
"StayInRoom": "Rester dans la salle"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "Condivisione...",
"Browser": "Browser",
"Monitor": "Monitor",
"Window": "Finestra"
"Window": "Finestra",
"MeetingEmptyTitle": "Riunione terminata?",
"MeetingEmptyMessage": "Tutti i partecipanti hanno abbandonato. Sarai disconnesso tra 1 minuto. Vuoi rimanere?",
"StayInRoom": "Rimani nella stanza"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "共有中...",
"Browser": "ブラウザ",
"Monitor": "モニター",
"Window": "ウィンドウ"
"Window": "ウィンドウ",
"MeetingEmptyTitle": "ミーティング終了?",
"MeetingEmptyMessage": "全参加者が退出しました。1分後に自動切断されます。ミーティングに残りますか?",
"StayInRoom": "ルームに残る"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "A partilhar...",
"Browser": "Navegador",
"Monitor": "Monitor",
"Window": "Janela"
"Window": "Janela",
"MeetingEmptyTitle": "Reunião encerrada?",
"MeetingEmptyMessage": "Todos os participantes saíram. Você será desconectado em 1 minuto. Deseja permanecer?",
"StayInRoom": "Permanecer na sala"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "Показ...",
"Browser": "Браузер",
"Monitor": "Монитор",
"Window": "Окно"
"Window": "Окно",
"MeetingEmptyTitle": "Совещание завершено?",
"MeetingEmptyMessage": "Все участники вышли. Вы будете отключены через 1 минуту. Хотите остаться?",
"StayInRoom": "Остаться в комнате"
}
}
+4 -1
View File
@@ -90,6 +90,9 @@
"Sharing": "共享中...",
"Browser": "浏览器",
"Monitor": "监视器",
"Window": "窗口"
"Window": "窗口",
"MeetingEmptyTitle": "会议已结束?",
"MeetingEmptyMessage": "所有参与者已离开。您将在1分钟后断开连接。是否要留在会议中?",
"StayInRoom": "留在房间"
}
}
+2 -1
View File
@@ -41,5 +41,6 @@ loadMetadata(love.icon, {
MeetingMinutes: `${icons}#meeting-minutes`
})
loadMetadata(love.sound, {
Knock: require('../assets/knock.wav')
Knock: require('../assets/knock.wav'),
MeetingEndNotification: require('../assets/meeting-end-notification.wav')
})
+3 -1
View File
@@ -61,9 +61,11 @@
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/workbench": "^0.6.16",
"@hcengineering/workbench-resources": "^0.6.1",
"@hcengineering/emoji": "^0.6.0",
"@hcengineering/theme": "^0.6.5",
"@livekit/krisp-noise-filter": "^0.3.0",
"@livekit/track-processors": "^0.5.6",
"livekit-client": "^2.13.3",
"livekit-client": "^2.15.5",
"svelte": "^4.2.19"
}
}
+44 -54
View File
@@ -1,17 +1,6 @@
import { get } from 'svelte/store'
import {
isCameraEnabled,
isConnected,
$isCurrentInstanceConnected,
isMicEnabled,
isSharingEnabled,
setCam,
setMic,
setShare
} from './utils'
import { getCurrentLocation, location } from '@hcengineering/ui'
import { location } from '@hcengineering/ui'
let key = 'love' + getCurrentLocation().path?.[2]
let key = ''
let bc: BroadcastChannel | undefined
@@ -20,7 +9,10 @@ location.subscribe((newLocation) => {
const newKey = 'love' + newLocation.path[2]
if (key !== newKey) {
key = newKey
bc?.close()
bc = new BroadcastChannel(key)
bc.onmessage = onMessage
sendMessage({ type: 'status_ask' })
}
}
})
@@ -55,50 +47,48 @@ interface BroadcastStatusMessage {
value: boolean
}
export function sendMessage (req: BroadcastMessage): void {
function sendMessage (req: BroadcastMessage): void {
bc?.postMessage(req)
}
if (bc !== undefined) {
bc.onmessage = async (e: MessageEvent<BroadcastMessage>) => {
if (e.data.type === 'set_mic') {
if ($isCurrentInstanceConnected) {
await setMic(e.data.value)
}
}
if (e.data.type === 'set_cam') {
if ($isCurrentInstanceConnected) {
await setCam(e.data.value)
}
}
if (e.data.type === 'set_share') {
if ($isCurrentInstanceConnected) {
await setShare(e.data.value)
}
}
if (e.data.type === 'share') {
isSharingEnabled.set(e.data.value)
}
if (e.data.type === 'mic') {
isMicEnabled.set(e.data.value)
}
if (e.data.type === 'cam') {
isCameraEnabled.set(e.data.value)
}
if (e.data.type === 'status_ask') {
if ($isCurrentInstanceConnected) {
sendMessage({ type: 'connect', value: true })
sendMessage({ type: 'mic', value: get(isMicEnabled) })
sendMessage({ type: 'cam', value: get(isCameraEnabled) })
sendMessage({ type: 'share', value: get(isSharingEnabled) })
}
}
if (e.data.type === 'connect') {
if (!$isCurrentInstanceConnected) {
isConnected.set(e.data.value)
}
async function onMessage (e: MessageEvent<BroadcastMessage>): Promise<void> {
/*
if (e.data.type === 'set_mic') {
if ($isCurrentInstanceConnected) {
await setMic(e.data.value)
}
}
if (e.data.type === 'set_cam') {
if ($isCurrentInstanceConnected) {
await setCam(e.data.value)
}
}
if (e.data.type === 'set_share') {
if ($isCurrentInstanceConnected) {
await setShare(e.data.value)
}
}
if (e.data.type === 'share') {
isSharingEnabled.set(e.data.value)
}
if (e.data.type === 'mic') {
isMicEnabled.set(e.data.value)
}
if (e.data.type === 'cam') {
isCameraEnabled.set(e.data.value)
}
if (e.data.type === 'status_ask') {
if ($isCurrentInstanceConnected) {
sendMessage({ type: 'connect', value: true })
sendMessage({ type: 'mic', value: get(isMicEnabled) })
sendMessage({ type: 'cam', value: get(isCameraEnabled) })
sendMessage({ type: 'share', value: get(isSharingEnabled) })
}
}
if (e.data.type === 'connect') {
if (!$isCurrentInstanceConnected) {
isConnected.set(e.data.value)
}
}
*/
}
sendMessage({ type: 'status_ask' })
@@ -33,15 +33,12 @@
} from '@hcengineering/ui'
import view, { Action } from '@hcengineering/view'
import { getActions } from '@hcengineering/view-resources'
import { toggleCamState, toggleMicState } from '@hcengineering/media-resources'
import { toggleCamState, toggleMicState, state } from '@hcengineering/media-resources'
import love from '../plugin'
import { currentRoom, myInfo, myOffice } from '../stores'
import {
isCameraEnabled,
isConnected,
isFullScreen,
isMicEnabled,
isRecording,
isRecordingAvailable,
isShareWithSound,
@@ -53,7 +50,8 @@
screenSharing,
setShare,
startTranscription,
stopTranscription
stopTranscription,
lk
} from '../utils'
import CamSettingPopup from './meeting/CamSettingPopup.svelte'
import ControlBarContainer from './ControlBarContainer.svelte'
@@ -61,6 +59,8 @@
import RoomAccessPopup from './RoomAccessPopup.svelte'
import RoomModal from './RoomModal.svelte'
import ShareSettingPopup from './ShareSettingPopup.svelte'
import { lkSessionConnected } from '../liveKitClient'
import emojiPlugin from '@hcengineering/emoji'
export let room: Room
export let canMaximize: boolean = true
@@ -74,6 +74,8 @@
$: allowCam = $currentRoom?.type === RoomType.Video
$: allowLeave = $myInfo?.room !== ($myOffice?._id ?? love.ids.Reception)
$: isMicEnabled = $state.microphone?.enabled === true
$: isCamEnabled = $state.camera?.enabled === true
async function changeShare (): Promise<void> {
const newValue = !$isSharingEnabled
@@ -137,16 +139,25 @@
function maximize (): void {
showPopup(RoomModal, { room }, 'full-centered')
}
function addReaction (event: MouseEvent): void {
showPopup(
emojiPlugin.component.EmojiPopup,
{},
event?.target as HTMLElement,
async (result) => {
const emoji = result?.text
if (emoji == null) return
void lk.localParticipant.sendChatMessage(emoji, { topic: 'reaction' })
},
() => {}
)
}
</script>
<ControlBarContainer bind:noLabel>
<!-- <svelte:fragment slot="right">
{#if $isConnected && isTranscriptionAllowed() && $isTranscription}
<RoomLanguageSelector {room} kind="icon" />
{/if}
</svelte:fragment> -->
<svelte:fragment slot="center">
{#if room._id !== love.ids.Reception}
<svelte:fragment slot="right">
{#if room._id !== love.ids.Reception && $lkSessionConnected}
<ModernButton
icon={roomAccessIcon[room.access]}
iconProps={{
@@ -164,12 +175,15 @@
on:click={setAccess}
/>
{/if}
{#if $isConnected}
</svelte:fragment>
<svelte:fragment slot="center">
{#if $lkSessionConnected}
<ModernButton icon={emojiPlugin.icon.Emoji} kind={'secondary'} size={'large'} on:click={addReaction} />
<SplitButton
size={'large'}
icon={$isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}
icon={isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}
showTooltip={{
label: $isMicEnabled ? love.string.Mute : love.string.UnMute,
label: isMicEnabled ? love.string.Mute : love.string.UnMute,
keys: micKeys
}}
action={toggleMicState}
@@ -180,9 +194,9 @@
{#if allowCam}
<SplitButton
size={'large'}
icon={$isCameraEnabled ? love.icon.CamEnabled : love.icon.CamDisabled}
icon={isCamEnabled ? love.icon.CamEnabled : love.icon.CamDisabled}
showTooltip={{
label: $isCameraEnabled ? love.string.StopVideo : love.string.StartVideo,
label: isCamEnabled ? love.string.StopVideo : love.string.StartVideo,
keys: camKeys
}}
action={toggleCamState}
@@ -199,7 +213,7 @@
fill: $isSharingEnabled ? 'var(--bg-negative-default)' : 'var(--bg-positive-default)'
}}
showTooltip={{ label: $isSharingEnabled ? love.string.StopShare : love.string.Share }}
disabled={($screenSharing && !$isSharingEnabled) || !$isConnected}
disabled={($screenSharing && !$isSharingEnabled) || !$lkSessionConnected}
action={changeShare}
secondIcon={IconUpOutline}
secondAction={shareSettings}
@@ -210,13 +224,13 @@
<ModernButton
icon={$isRecording ? love.icon.StopRecord : love.icon.Record}
tooltip={{ label: $isRecording ? love.string.StopRecord : love.string.Record }}
disabled={!$isConnected}
disabled={!$lkSessionConnected}
kind={'secondary'}
size={'large'}
on:click={() => record(room)}
/>
{/if}
{#if hasAccountRole(getCurrentAccount(), AccountRole.User) && isTranscriptionAllowed() && $isConnected}
{#if hasAccountRole(getCurrentAccount(), AccountRole.User) && isTranscriptionAllowed() && $lkSessionConnected}
<ModernButton
icon={view.icon.Feather}
iconProps={$isTranscription ? { fill: 'var(--button-negative-BackgroundColor)' } : {}}
@@ -232,10 +246,27 @@
}}
/>
{/if}
{:else}
<ModernButton
icon={roomAccessIcon[room.access]}
iconProps={{
fill:
room.access === RoomAccess.Open
? 'var(--bg-positive-default)'
: room.access === RoomAccess.DND
? 'var(--bg-negative-default)'
: 'currentColor'
}}
tooltip={{ label: love.string.ChangeAccess }}
kind={'secondary'}
size={'large'}
disabled={isOffice(room) && room.person !== me}
on:click={setAccess}
/>
{/if}
</svelte:fragment>
<svelte:fragment slot="left">
{#if $isConnected && withVideo && onFullScreen}
{#if $lkSessionConnected && withVideo && onFullScreen}
<ModernButton
icon={$isFullScreen ? love.icon.ExitFullScreen : love.icon.FullScreen}
tooltip={{
@@ -250,7 +281,7 @@
/>
{/if}
{#if ($screenSharing || room.type === RoomType.Video) && $isConnected && canMaximize}
{#if ($screenSharing || room.type === RoomType.Video) && $lkSessionConnected && canMaximize}
<ModernButton
icon={IconMaximize}
tooltip={{
@@ -263,7 +294,7 @@
on:click={maximize}
/>
{/if}
{#if $isConnected && moreItems.length > 0}
{#if $lkSessionConnected && moreItems.length > 0}
<ButtonMenu
items={moreItems}
icon={IconMoreV}
@@ -48,7 +48,6 @@
disconnect,
endMeeting,
getRoomName,
isCurrentInstanceConnected,
leaveRoom,
screenSharing
} from '../utils'
@@ -60,6 +59,7 @@
import RoomPopup from './RoomPopup.svelte'
import RoomButton from './RoomButton.svelte'
import { getPersonByPersonRef } from '@hcengineering/contact-resources'
import { lkSessionConnected } from '../liveKitClient'
const client = getClient()
@@ -194,7 +194,7 @@
}
}
$: checkOwnRoomConnection($infos, $myInfo, $myOffice, $isCurrentInstanceConnected)
$: checkOwnRoomConnection($infos, $myInfo, $myOffice, $lkSessionConnected)
const myInvitesCategory = 'myInvites'
@@ -232,7 +232,7 @@
return
}
if ($isCurrentInstanceConnected) {
if ($lkSessionConnected) {
const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: love.ids.MeetingWidget })[0]
if (widget === undefined) return
@@ -252,7 +252,7 @@
$: checkActiveVideo(
$location,
$isCurrentInstanceConnected && ($currentRoom?.type === RoomType.Video || $screenSharing),
$lkSessionConnected && ($currentRoom?.type === RoomType.Video || $screenSharing),
$currentRoom?._id
)
@@ -283,7 +283,7 @@
})
const beforeUnloadListener = () => {
if ($myInfo !== undefined && $isCurrentInstanceConnected) {
if ($myInfo !== undefined && $lkSessionConnected) {
if ($myOffice !== undefined && $myInfo.room === $myOffice._id) {
endMeeting($myOffice, $rooms, $infos, $myInfo)
} else {
@@ -19,8 +19,9 @@
import { IntlString } from '@hcengineering/platform'
import love from '../plugin'
import { getRoomName, tryConnect, isConnected } from '../utils'
import { getRoomName, tryConnect } from '../utils'
import { infos, invites, myInfo, myRequests, selectedRoomPlace, myOffice, currentRoom } from '../stores'
import { lkSessionConnected } from '../liveKitClient'
export let object: Room
@@ -54,7 +55,7 @@
selectedRoomPlace.set(undefined)
}
$: connecting = tryConnecting || ($currentRoom?._id === object._id && !$isConnected)
$: connecting = tryConnecting || ($currentRoom?._id === object._id && !$lkSessionConnected)
let connectLabel: IntlString = $infos.some(({ room }) => room === object._id)
? love.string.JoinMeeting
@@ -100,7 +101,7 @@
<div class="name">
<EditBox disabled={true} placeholder={love.string.Room} bind:value={roomName} focusIndex={1} />
</div>
{#if showConnectionButton(object, connecting, $isConnected, $infos, $myOffice, $currentRoom)}
{#if showConnectionButton(object, connecting, $lkSessionConnected, $infos, $myOffice, $currentRoom)}
<ModernButton label={connectLabel} size="large" kind={'primary'} on:click={connect} loading={connecting} />
{/if}
</div>
@@ -21,8 +21,9 @@
import Hall from './Hall.svelte'
import { getMetadata } from '@hcengineering/platform'
import love from '../plugin'
import { tryConnect, isConnected, isCurrentInstanceConnected, screenSharing } from '../utils'
import { tryConnect, screenSharing } from '../utils'
import { infos, invites, myInfo, myRequests, waitForOfficeLoaded, currentRoom } from '../stores'
import { lkSessionConnected } from '../liveKitClient'
const localNav: boolean = $deviceInfo.navigator.visible
const savedNav = localStorage.getItem('love-visibleNav')
@@ -46,8 +47,7 @@
if (room === undefined) return
if (
!$isConnected &&
!$isCurrentInstanceConnected &&
!$lkSessionConnected &&
(room.type === RoomType.Video || $screenSharing) &&
$myInfo?.sessionId &&
$myInfo.sessionId === getMetadata(presentation.metadata.SessionId)
@@ -7,7 +7,8 @@
import { createEventDispatcher, onMount } from 'svelte'
import love from '../plugin'
import { currentRoom, infos, myInfo, myOffice, rooms, currentMeetingMinutes } from '../stores'
import { endMeeting, isCurrentInstanceConnected, leaveRoom } from '../utils'
import { endMeeting, leaveRoom } from '../utils'
import { lkSessionConnected } from '../liveKitClient'
export let limit: number = 4
@@ -60,7 +61,7 @@
})
</script>
{#if $isCurrentInstanceConnected && $currentRoom != null}
{#if $lkSessionConnected && $currentRoom != null}
{@const overLimit = participants.length > limit}
<div class="m-1 p-2">
@@ -1,164 +0,0 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Person, formatName } from '@hcengineering/contact'
import { Avatar, getPersonByPersonRefStore } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { Loading } from '@hcengineering/ui'
import { currentRoomAudioLevels } from '../utils'
import MicDisabled from './icons/MicDisabled.svelte'
export let _id: string
export let name: string
export let muted: boolean
export let mirror: boolean
export let connecting: boolean = false
let parent: HTMLDivElement
let activeTrack: boolean = false
export function appendChild (track: HTMLMediaElement, enabled: boolean = true): void {
const video = parent.querySelector('.video')
if (video != null) {
video.remove()
}
track.classList.add('video')
if (!enabled) track.classList.add('hidden')
parent.appendChild(track)
activeTrack = enabled
}
export function setTrackMuted (value: boolean): void {
const video = parent.querySelector('video')
if (video != null) {
if (value) {
video.classList.add('hidden')
} else {
video.classList.remove('hidden')
}
}
activeTrack = !value
}
$: personByRefStore = getPersonByPersonRefStore([_id as Ref<Person>])
$: user = $personByRefStore.get(_id as Ref<Person>)
$: speach = $currentRoomAudioLevels.get(_id as Ref<Person>) ?? 0
</script>
<div id={_id} class="parent" class:speach={speach > 0}>
<div bind:this={parent} class="cover" class:active={activeTrack} class:mirror={mirror && activeTrack} />
<div class="ava">
<Avatar size={'full'} {name} person={user} showStatus={false} />
</div>
<div class="label" class:withIcon={muted || connecting}>
{#if connecting}<Loading size={'small'} shrink />{/if}
{#if muted}<MicDisabled fill={'var(--bg-negative-default)'} size={'small'} />{/if}
<span class="overflow-label">{formatName(name)}</span>
</div>
</div>
<style lang="scss">
:global(.video) {
object-fit: cover;
border-radius: 0.75rem;
height: 100%;
width: 100%;
}
:global(.hidden) {
display: none;
}
.cover {
object-fit: cover;
border-radius: 0.75rem;
height: 100%;
width: 100%;
aspect-ratio: 1280 / 720;
display: flex;
justify-content: center;
align-items: center;
&.mirror {
transform: scaleX(-1);
}
&.active + .ava {
display: none;
}
&:not(.active) {
background-color: black;
}
}
.ava {
overflow: hidden;
position: absolute;
top: 50%;
left: 50%;
height: 50%;
aspect-ratio: 1;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.parent {
position: relative;
flex-shrink: 0;
height: max-content;
min-height: 0;
max-height: 100%;
background-color: black;
border-radius: 0.75rem;
.label {
overflow: hidden;
text-overflow: ellipsis;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
top: 0.25rem;
left: 0.25rem;
max-width: 12rem;
font-weight: 500;
font-size: 0.75rem;
line-height: 1rem;
color: var(--white-color);
background-color: rgba(0, 0, 0, 0.5);
border-radius: 0.5rem;
backdrop-filter: blur(3px);
&.withIcon {
padding-left: 0.25rem;
}
}
&.speach::before,
&.speach::after {
position: absolute;
content: '';
inset: 0;
border-radius: 0.75rem;
z-index: 1;
}
&.speach::before {
border: 3px solid var(--border-talk-indication-secondary);
}
&.speach::after {
border: 2px solid var(--border-talk-indication-primary);
}
}
</style>
@@ -20,8 +20,9 @@
import { JoinRequest, RequestStatus } from '@hcengineering/love'
import love from '../plugin'
import { myInfo, myOffice } from '../stores'
import { connectRoom, isConnected } from '../utils'
import { connectRoom } from '../utils'
import { onDestroy, onMount } from 'svelte'
import { lkSessionConnected } from '../liveKitClient'
export let request: JoinRequest
@@ -33,7 +34,7 @@
async function accept (): Promise<void> {
await client.update(request, { status: RequestStatus.Approved })
if (request.room === $myOffice?._id && !$isConnected) {
if (request.room === $myOffice?._id && !$lkSessionConnected) {
const person = await getPersonByPersonRef(getCurrentEmployee())
if (person == null) return
await connectRoom(0, 0, $myInfo, person, $myOffice)
+18 -295
View File
@@ -13,223 +13,30 @@
// limitations under the License.
-->
<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import presentation, { ActionContext } from '@hcengineering/presentation'
import { getPersonRefByPersonIdCb } from '@hcengineering/contact-resources'
import { Room as TypeRoom } from '@hcengineering/love'
import { getMetadata } from '@hcengineering/platform'
import { Label, Loading, resizeObserver, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import {
LocalParticipant,
LocalTrackPublication,
Participant,
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
RoomEvent,
Track,
TrackPublication
} from 'livekit-client'
import { onDestroy, onMount, tick } from 'svelte'
import { aiBotSocialIdentityStore } from '@hcengineering/ai-bot-resources'
import { Label, Loading, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { onDestroy, onMount } from 'svelte'
import love from '../plugin'
import { waitForOfficeLoaded, currentRoom, infos, invites, myInfo, myRequests } from '../stores'
import {
awaitConnect,
isConnected,
isCurrentInstanceConnected,
isFullScreen,
lk,
screenSharing,
tryConnect
} from '../utils'
import { isFullScreen, tryConnect } from '../utils'
import ControlBar from './ControlBar.svelte'
import ParticipantView from './ParticipantView.svelte'
import { Ref } from '@hcengineering/core'
import { Person } from '@hcengineering/contact'
import { lkSessionConnected } from '../liveKitClient'
import ParticipantsListView from './meeting/ParticipantsListView.svelte'
import ScreenSharingView from './meeting/ScreenSharingView.svelte'
export let withVideo: boolean
export let canMaximize: boolean = true
export let room: TypeRoom
interface ParticipantData {
_id: string
name: string
connecting: boolean
muted: boolean
mirror: boolean
isAgent: boolean
}
let participants: ParticipantData[] = []
const participantElements: ParticipantView[] = []
let screen: HTMLVideoElement
let roomEl: HTMLDivElement
let aiPersonRef: Ref<Person> | undefined
$: if ($aiBotSocialIdentityStore != null) {
getPersonRefByPersonIdCb($aiBotSocialIdentityStore?._id, (ref) => {
if (ref != null) {
aiPersonRef = ref
}
})
} else {
aiPersonRef = undefined
}
function handleTrackSubscribed (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant
): void {
if (track.kind === Track.Kind.Video) {
if (track.source === Track.Source.ScreenShare) {
track.attach(screen)
} else {
const element = track.attach()
attachTrack(element, participant)
}
updateStyle(getActiveParticipants(participants).length, $screenSharing)
} else {
const part = participants.find((p) => p._id === participant.identity)
if (part !== undefined) {
part.muted = publication.isMuted
participants = participants
}
}
}
function handleTrackUnsubscribed (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant
): void {
if (track.kind === Track.Kind.Video) {
if (track.source !== Track.Source.ScreenShare) {
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1) {
participants.splice(index, 1)
participants = participants
}
} else {
track.detach(screen)
}
updateStyle(getActiveParticipants(participants).length, $screenSharing)
}
}
function handleLocalTrack (publication: LocalTrackPublication, participant: LocalParticipant): void {
if (publication.track?.kind === Track.Kind.Video) {
if (publication.track.source === Track.Source.ScreenShare) {
publication.track.attach(screen)
} else {
const element = publication.track.attach()
void attachTrack(element, participant)
}
updateStyle(getActiveParticipants(participants).length, $screenSharing)
} else {
const part = participants.find((p) => p._id === participant.identity)
if (part !== undefined) {
part.muted = publication.isMuted
participants = participants
}
}
}
async function attachTrack (element: HTMLMediaElement, participant: Participant): Promise<void> {
let index = participants.findIndex((p) => p._id === participant.identity)
if (index === -1) {
index = participants.push({
_id: participant.identity,
name: participant.name ?? '',
muted: !participant.isMicrophoneEnabled,
mirror: participant.isLocal,
connecting: false,
isAgent: participant.isAgent
})
}
participants = participants
for (let attempt = 0; attempt < 10; attempt++) {
await tick()
index = participants.findIndex((p) => p._id === participant.identity)
const el = participantElements[index]
if (el != null) {
el.appendChild(element)
return
}
}
console.error('Failed to attach track after 10 attempts')
Analytics.handleError(new Error(`Failed to attach track after 10 attempts, participant: ${participant.identity}`))
}
function attachParticipant (participant: Participant): void {
const current = participants.find((p) => p._id === participant.identity)
if (current !== undefined) {
current.connecting = false
current.muted = !participant.isMicrophoneEnabled
current.mirror = participant.isLocal
participants = participants
return
}
const value: ParticipantData = {
_id: participant.identity,
name: participant.name ?? '',
muted: !participant.isMicrophoneEnabled,
mirror: participant.isLocal,
connecting: false,
isAgent: participant.isAgent
}
participants.push(value)
participants = participants
updateStyle(getActiveParticipants(participants).length, $screenSharing)
}
function handleParticipantDisconnected (participant: RemoteParticipant): void {
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1) {
participants.splice(index, 1)
participants = participants
}
updateStyle(getActiveParticipants(participants).length, $screenSharing)
}
function muteHandler (publication: TrackPublication, participant: Participant): void {
if (publication.kind === Track.Kind.Video) {
if (publication.source === Track.Source.ScreenShare) {
return
}
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1 && participantElements[index] != null) {
participantElements[index].setTrackMuted(publication.isMuted)
}
} else {
const part = participants.find((p) => p._id === participant.identity)
if (part !== undefined) {
part.muted = publication.isMuted
participants = participants
}
}
}
let withScreenSharing: boolean = false
let loading: boolean = false
let configured: boolean = false
function handleLocalTrackUnsubscribed (publication: LocalTrackPublication, participant: LocalParticipant): void {
if (publication?.track?.kind === Track.Kind.Video) {
if (publication.track.source === Track.Source.ScreenShare) {
publication.track.detach(screen)
updateStyle(getActiveParticipants(participants).length, $screenSharing)
} else {
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1) {
participants.splice(index, 1)
participants = participants
}
}
}
}
onMount(async () => {
loading = true
@@ -243,48 +50,10 @@
await waitForOfficeLoaded()
if (
!$isConnected &&
!$isCurrentInstanceConnected &&
$myInfo?.sessionId === getMetadata(presentation.metadata.SessionId)
) {
if (!$lkSessionConnected && $myInfo?.sessionId === getMetadata(presentation.metadata.SessionId)) {
const info = $infos.filter((p) => p.room === room._id)
await tryConnect($myInfo, room, info, $myRequests, $invites)
}
await awaitConnect()
for (const participant of lk.remoteParticipants.values()) {
attachParticipant(participant)
for (const publication of participant.trackPublications.values()) {
if (publication.track !== undefined && publication.track.kind === Track.Kind.Video) {
if (publication.track.source === Track.Source.ScreenShare) {
publication.track.attach(screen)
} else {
const element = publication.track.attach()
await attachTrack(element, participant)
}
}
}
}
attachParticipant(lk.localParticipant)
for (const publication of lk.localParticipant.trackPublications.values()) {
if (publication.track !== undefined && publication.track.kind === Track.Kind.Video) {
if (publication.track.source === Track.Source.ScreenShare) {
publication.track.attach(screen)
} else {
const element = publication.track.attach()
await attachTrack(element, lk.localParticipant)
}
}
}
lk.on(RoomEvent.ParticipantConnected, attachParticipant)
lk.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
lk.on(RoomEvent.TrackMuted, muteHandler)
lk.on(RoomEvent.TrackUnmuted, muteHandler)
lk.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
lk.on(RoomEvent.LocalTrackPublished, handleLocalTrack)
lk.on(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnsubscribed)
roomEl && roomEl.addEventListener('fullscreenchange', handleFullScreen)
loading = false
})
@@ -292,39 +61,8 @@
let gridStyle = ''
let columns: number = 0
let rows: number = 0
let roomWidth: number
let roomHeight: number
onDestroy(
infos.subscribe((data) => {
for (const info of data) {
if (info.room !== room._id) continue
const current = participants.find((p) => p._id === info.person)
if (current !== undefined) continue
const value: ParticipantData = {
_id: info.person,
name: info.name,
muted: true,
mirror: false,
connecting: true,
isAgent: aiPersonRef === info.person
}
participants.push(value)
}
participants = participants
updateStyle(getActiveParticipants(participants).length, $screenSharing)
})
)
onDestroy(() => {
lk.off(RoomEvent.ParticipantConnected, attachParticipant)
lk.off(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
lk.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.off(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
lk.off(RoomEvent.LocalTrackPublished, handleLocalTrack)
lk.off(RoomEvent.TrackMuted, muteHandler)
lk.off(RoomEvent.TrackUnmuted, muteHandler)
lk.off(RoomEvent.LocalTrackUnpublished, handleLocalTrackUnsubscribed)
roomEl.removeEventListener('fullscreenchange', handleFullScreen)
})
@@ -387,21 +125,11 @@
}
$: if (((document.fullscreenElement && !$isFullScreen) || $isFullScreen) && roomEl) checkFullscreen()
function getActiveParticipants (participants: ParticipantData[]): ParticipantData[] {
return participants.filter((p) => !p.isAgent || $infos.some(({ person }) => person === p._id))
}
$: activeParticipants = getActiveParticipants(participants)
</script>
<div bind:this={roomEl} class="flex-col-center w-full h-full" class:theme-dark={$isFullScreen}>
<ActionContext context={{ mode: 'workbench' }} />
{#if $isConnected && !$isCurrentInstanceConnected}
<div class="flex justify-center error h-full w-full clear-mins">
<Label label={love.string.AnotherWindowError} />
</div>
{:else if !configured}
{#if !configured}
<div class="flex justify-center error h-full w-full clear-mins">
<Label label={love.string.ServiceNotConfigured} />
</div>
@@ -410,27 +138,22 @@
{/if}
<div
class="room-container"
class:sharing={$screenSharing}
class:sharing={withScreenSharing}
class:many={columns > 3}
class:hidden={loading}
class:mobile={$deviceInfo.isMobile}
>
<div class="screenContainer">
<video class="screen" bind:this={screen}></video>
<ScreenSharingView bind:hasActiveTrack={withScreenSharing} />
</div>
{#if withVideo}
<div
use:resizeObserver={(element) => {
roomWidth = element.clientWidth
roomHeight = element.clientHeight
}}
class="videoGrid"
style={$screenSharing ? '' : gridStyle}
class:scroll-m-0={$screenSharing}
>
{#each activeParticipants as participant, i (participant._id)}
<ParticipantView bind:this={participantElements[i]} {...participant} />
{/each}
<div class="videoGrid" style={withScreenSharing ? '' : gridStyle} class:scroll-m-0={withScreenSharing}>
<ParticipantsListView
room={room._id}
on:participantsCount={(evt) => {
updateStyle(evt.detail, withScreenSharing)
}}
/>
</div>
{/if}
</div>
@@ -45,16 +45,13 @@
import { getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import { getObjectLinkFragment } from '@hcengineering/view-resources'
import { toggleCamState, toggleMicState } from '@hcengineering/media-resources'
import { toggleCamState, toggleMicState, state } from '@hcengineering/media-resources'
import { createEventDispatcher } from 'svelte'
import love from '../plugin'
import { currentMeetingMinutes, currentRoom, infos, invites, myInfo, myOffice, myRequests, rooms } from '../stores'
import {
endMeeting,
getRoomName,
isCameraEnabled,
isConnected,
isMicEnabled,
isShareWithSound,
isSharingEnabled,
leaveRoom,
@@ -62,6 +59,7 @@
setShare,
tryConnect
} from '../utils'
import { lkSessionConnected } from '../liveKitClient'
import CamSettingPopup from './meeting/CamSettingPopup.svelte'
import MicSettingPopup from './meeting/MicSettingPopup.svelte'
import RoomAccessPopup from './RoomAccessPopup.svelte'
@@ -92,6 +90,8 @@
const allowShare: boolean = true
$: allowCam = $currentRoom?.type === RoomType.Video
$: isMicEnabled = $state.microphone?.enabled === true
$: isCamEnabled = $state.camera?.enabled === true
const dispatch = createEventDispatcher()
@@ -188,14 +188,14 @@
</div>
</Scroller>
</div>
{#if joined && $isConnected}
{#if joined && $lkSessionConnected}
<div class="room-btns" class:no-video={!allowCam}>
<SplitButton
size={'large'}
icon={$isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}
label={$isMicEnabled ? love.string.Mute : love.string.UnMute}
icon={isMicEnabled ? love.icon.MicEnabled : love.icon.MicDisabled}
label={isMicEnabled ? love.string.Mute : love.string.UnMute}
showTooltip={{
label: $isMicEnabled ? love.string.Mute : love.string.UnMute
label: isMicEnabled ? love.string.Mute : love.string.UnMute
}}
action={toggleMicState}
secondIcon={IconUpOutline}
@@ -205,10 +205,10 @@
{#if allowCam}
<SplitButton
size={'large'}
icon={$isCameraEnabled ? love.icon.CamEnabled : love.icon.CamDisabled}
label={$isCameraEnabled ? love.string.StopVideo : love.string.StartVideo}
icon={isCamEnabled ? love.icon.CamEnabled : love.icon.CamDisabled}
label={isCamEnabled ? love.string.StopVideo : love.string.StartVideo}
showTooltip={{
label: $isCameraEnabled ? love.string.StopVideo : love.string.StartVideo
label: isCamEnabled ? love.string.StopVideo : love.string.StartVideo
}}
action={toggleCamState}
secondIcon={IconUpOutline}
@@ -221,7 +221,7 @@
size={'large'}
icon={$isSharingEnabled ? love.icon.SharingEnabled : love.icon.SharingDisabled}
showTooltip={{ label: $isSharingEnabled ? love.string.StopShare : love.string.Share }}
disabled={($screenSharing && !$isSharingEnabled) || !$isConnected}
disabled={($screenSharing && !$isSharingEnabled) || !$lkSessionConnected}
action={changeShare}
secondIcon={IconUpOutline}
secondAction={shareSettings}
@@ -23,9 +23,10 @@
import love from '../plugin'
import { myInfo, selectedRoomPlace, currentRoom, currentMeetingMinutes } from '../stores'
import { getRoomLabel, isConnected } from '../utils'
import { getRoomLabel } from '../utils'
import PersonActionPopup from './PersonActionPopup.svelte'
import { IntlString } from '@hcengineering/platform'
import { lkSessionConnected } from '../liveKitClient'
export let room: Room
export let info: ParticipantInfo[]
@@ -71,7 +72,7 @@
async function openRoom (x: number, y: number): Promise<void> {
const client = getClient()
const hierarchy = client.getHierarchy()
if ($isConnected && $currentRoom?._id === room._id) {
if ($lkSessionConnected && $currentRoom?._id === room._id) {
let meeting = $currentMeetingMinutes
if (meeting?.attachedTo !== room._id || meeting?.status !== MeetingStatus.Active) {
meeting = await client.findOne(love.class.MeetingMinutes, {
@@ -16,10 +16,11 @@
import { tooltip, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import love from '../plugin'
import { isConnected, isSharingEnabled, isShareWithSound, screenSharing, setShare } from '../utils'
import { isSharingEnabled, isShareWithSound, screenSharing, setShare } from '../utils'
import SharingStatePopup from './SharingStatePopup.svelte'
import IconShare from './icons/Share.svelte'
import { lkSessionConnected } from '../liveKitClient'
let disabled: boolean = false
let pressed: boolean = false
@@ -38,10 +39,10 @@
void setShare(true, audio)
}
$: disabled = !$screenSharing && !$isConnected
$: disabled = !$screenSharing && !$lkSessionConnected
</script>
{#if $isConnected}
{#if $lkSessionConnected}
{#if $isSharingEnabled}
<button
class="hulyStatusBarButton mini positive positiveContent"
@@ -13,249 +13,22 @@
// limitations under the License.
-->
<script lang="ts">
import { aiBotSocialIdentityStore } from '@hcengineering/ai-bot-resources'
import { getPersonRefByPersonIdCb } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { Room as TypeRoom } from '@hcengineering/love'
import { Scroller } from '@hcengineering/ui'
import {
LocalParticipant,
LocalTrackPublication,
Participant,
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
RoomEvent,
Track,
TrackPublication
} from 'livekit-client'
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte'
import { createEventDispatcher } from 'svelte'
import { infos } from '../stores'
import { awaitConnect, isSharingEnabled, lk, screenSharing } from '../utils'
import ParticipantView from './ParticipantView.svelte'
import { Person } from '@hcengineering/contact'
import ParticipantsListView from './meeting/ParticipantsListView.svelte'
import ScreenSharingView from './meeting/ScreenSharingView.svelte'
export let isDock: boolean = false
export let room: Ref<TypeRoom>
interface ParticipantData {
_id: string
name: string
muted: boolean
mirror: boolean
connecting: boolean
isAgent: boolean
}
let aiPersonRef: Ref<Person> | undefined
$: if ($aiBotSocialIdentityStore != null) {
getPersonRefByPersonIdCb($aiBotSocialIdentityStore?._id, (ref) => {
if (ref != null) {
aiPersonRef = ref
}
})
} else {
aiPersonRef = undefined
}
const dispatch = createEventDispatcher()
let participants: ParticipantData[] = []
const participantElements: ParticipantView[] = []
let screen: HTMLVideoElement
function handleTrackSubscribed (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant
): void {
if (track.kind === Track.Kind.Video) {
if (track.source === Track.Source.ScreenShare) {
track.attach(screen)
} else {
const element = track.attach()
attachTrack(element, participant)
}
} else {
const part = participants.find((p) => p._id === participant.identity)
if (part !== undefined) {
part.muted = publication.isMuted
participants = participants
}
}
}
function handleTrackUnsubscribed (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant
): void {
if (track.kind === Track.Kind.Video) {
if (track.source !== Track.Source.ScreenShare) {
const part = participants.find((p) => p._id === participant.identity)
if (part !== undefined) {
participants = participants
}
}
}
}
function handleLocalTrack (publication: LocalTrackPublication, participant: LocalParticipant): void {
if (publication.track?.kind === Track.Kind.Video) {
if (publication.track.source === Track.Source.ScreenShare) {
publication.track.attach(screen)
} else {
const element = publication.track.attach()
void attachTrack(element, participant)
}
} else {
const part = participants.find((p) => p._id === participant.identity)
if (part !== undefined) {
part.muted = publication.isMuted
participants = participants
}
}
}
async function attachTrack (element: HTMLMediaElement, participant: Participant): Promise<void> {
let index = participants.findIndex((p) => p._id === participant.identity)
if (index === -1) {
index = participants.push({
_id: participant.identity,
name: participant.name ?? '',
muted: !participant.isMicrophoneEnabled,
mirror: participant.isLocal,
connecting: false,
isAgent: participant.isAgent
})
}
participants = participants
participantElements.length = participants.length
await tick()
participantElements[index]?.appendChild(element, participant.isCameraEnabled)
}
function attachParticipant (participant: Participant): void {
const current = participants.find((p) => p._id === participant.identity)
if (current !== undefined) {
current.connecting = false
current.muted = !participant.isMicrophoneEnabled
current.mirror = participant.isLocal
participants = participants
return
}
const value: ParticipantData = {
_id: participant.identity,
name: participant.name ?? '',
muted: !participant.isMicrophoneEnabled,
mirror: participant.isLocal,
connecting: false,
isAgent: participant.isAgent
}
participants.push(value)
participants = participants
}
function handleParticipantDisconnected (participant: RemoteParticipant): void {
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1) {
participants.splice(index, 1)
participants = participants
}
}
function muteHandler (publication: TrackPublication, participant: Participant): void {
if (publication.kind === Track.Kind.Video) {
if (publication.source === Track.Source.ScreenShare) {
return
}
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1 && participantElements[index] != null) {
participantElements[index].setTrackMuted(publication.isMuted)
}
} else {
const part = participants.find((p) => p._id === participant.identity)
if (part !== undefined) {
part.muted = publication.isMuted
participants = participants
}
}
}
onMount(async () => {
await awaitConnect()
for (const participant of lk.remoteParticipants.values()) {
attachParticipant(participant)
for (const publication of participant.trackPublications.values()) {
if (publication.track !== undefined && publication.track.kind === Track.Kind.Video) {
if (publication.track.source === Track.Source.ScreenShare) {
publication.track.attach(screen)
} else {
const element = publication.track.attach()
await attachTrack(element, participant)
}
}
}
}
attachParticipant(lk.localParticipant)
for (const publication of lk.localParticipant.trackPublications.values()) {
if (publication.track !== undefined && publication.track.kind === Track.Kind.Video) {
if (publication.track.source === Track.Source.ScreenShare) {
publication.track.attach(screen)
} else {
const element = publication.track.attach()
await attachTrack(element, lk.localParticipant)
}
}
}
lk.on(RoomEvent.ParticipantConnected, attachParticipant)
lk.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
lk.on(RoomEvent.TrackMuted, muteHandler)
lk.on(RoomEvent.TrackUnmuted, muteHandler)
lk.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
lk.on(RoomEvent.LocalTrackPublished, handleLocalTrack)
})
onDestroy(
infos.subscribe((data) => {
for (const info of data) {
if (info.room !== room) continue
const current = participants.find((p) => p._id === info.person)
if (current !== undefined) continue
const value: ParticipantData = {
_id: info.person,
name: info.name,
muted: true,
mirror: false,
connecting: true,
isAgent: info.person === aiPersonRef
}
participants.push(value)
}
participants = participants
})
)
onDestroy(() => {
lk.off(RoomEvent.ParticipantConnected, attachParticipant)
lk.off(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
lk.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.off(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
lk.off(RoomEvent.LocalTrackPublished, handleLocalTrack)
lk.off(RoomEvent.TrackMuted, muteHandler)
lk.off(RoomEvent.TrackUnmuted, muteHandler)
})
let withScreenSharing: boolean = false
let divScroll: HTMLElement
export function canClose (): boolean {
return false
}
$: dispatchFit($isSharingEnabled)
$: dispatchFit(participants.length > 0)
const dispatchFit = (_?: boolean): void => {
setTimeout(() => {
if (divScroll) {
@@ -265,16 +38,12 @@
}, 10)
}
function getActiveParticipants (participants: ParticipantData[]): ParticipantData[] {
return participants.filter((p) => !p.isAgent || $infos.some(({ person }) => person === p._id))
}
$: activeParticipants = getActiveParticipants(participants)
$: dispatchFit(withScreenSharing)
</script>
<div class="antiPopup videoPopup-container" class:isDock>
<div class="screenContainer" class:hidden={!$screenSharing || $isSharingEnabled}>
<video class="screen" bind:this={screen}></video>
<div class="screenContainer" class:hidden={!withScreenSharing}>
<ScreenSharingView showLocalTrack={false} bind:hasActiveTrack={withScreenSharing} />
</div>
<Scroller
bind:divScroll
@@ -285,11 +54,12 @@
stickedScrollBars
>
<div class="videoGrid">
{#each activeParticipants as participant, i (participant._id)}
<div class="video">
<ParticipantView bind:this={participantElements[i]} {...participant} />
</div>
{/each}
<ParticipantsListView
{room}
on:participantsCount={(evt) => {
dispatchFit(evt.detail > 0)
}}
/>
</div>
</Scroller>
<div class="antiNav-space" />
@@ -313,14 +83,6 @@
border: none;
box-shadow: none;
}
.header {
display: flex;
justify-content: space-between;
flex-shrink: 0;
margin: 0.5rem 1rem;
min-width: 0;
min-height: 0;
}
}
.screenContainer {
@@ -337,12 +99,6 @@
&.hidden {
display: none;
}
.screen {
object-fit: contain;
max-width: 100%;
max-height: 100%;
border-radius: 0.75rem;
}
}
.videoGrid {
@@ -3,7 +3,8 @@
import { RemoteParticipant, RemoteTrack, RemoteTrackPublication, RoomEvent, Track } from 'livekit-client'
import { onDestroy, onMount } from 'svelte'
import love from '../plugin'
import { disconnect, isCurrentInstanceConnected, lk } from '../utils'
import { disconnect, lk } from '../utils'
import { lkSessionConnected } from '../liveKitClient'
let parentElement: HTMLDivElement
@@ -41,7 +42,7 @@
onDestroy(async () => {
lk.off(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.off(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
if ($isCurrentInstanceConnected) {
if ($lkSessionConnected) {
await disconnect()
}
})
@@ -0,0 +1,33 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { IconSize } from '@hcengineering/ui'
export let size: IconSize = 'small'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path
d="M24,25a15.1,15.1,0,0,0-11.7,5.5A2,2,0,0,0,15.4,33,11.5,11.5,0,0,1,24,29a11.5,11.5,0,0,1,8.6,4,2,2,0,1,0,3.1-2.5A15.1,15.1,0,0,0,24,25Z"
/>
<path
d="M24,17A22.9,22.9,0,0,0,7.5,23.9a2,2,0,0,0-.2,2.6,1.9,1.9,0,0,0,3,.2,19.3,19.3,0,0,1,27.4,0,1.9,1.9,0,0,0,3-.2,2,2,0,0,0-.2-2.6A22.9,22.9,0,0,0,24,17Z"
/>
<path
d="M45.4,17.4a31.5,31.5,0,0,0-42.8,0,2.1,2.1,0,0,0-.2,2.7h0a1.9,1.9,0,0,0,3,.2,27.3,27.3,0,0,1,37.2,0,1.9,1.9,0,0,0,3-.2h0A2.1,2.1,0,0,0,45.4,17.4Z"
/>
<circle cx="24" cy="38" r="5" />
</svg>
@@ -0,0 +1,37 @@
<script lang="ts">
import { Button, Notification, NotificationToast } from '@hcengineering/ui'
import { myInfo, myOffice } from '../../stores'
import { leaveRoom } from '../../utils'
import love from '../../plugin'
import { onMount } from 'svelte'
import { playSound } from '@hcengineering/presentation'
export let onRemove: () => void
export let notification: Notification
function stayInMeeting (e: MouseEvent): void {
onRemove()
}
async function leaveMeeting (e: MouseEvent): Promise<void> {
await leaveRoom($myInfo, $myOffice)
onRemove()
}
onMount(async () => {
await playSound(love.sound.MeetingEndNotification)
})
</script>
<NotificationToast title={notification.title} severity={notification.severity} onClose={onRemove}>
<svelte:fragment slot="content">
{notification.subTitle}
</svelte:fragment>
<svelte:fragment slot="buttons">
<div style="width: auto" />
<div class="flex-between gap-2">
<Button label={love.string.LeaveRoom} stopPropagation={false} kind="negative" on:click={leaveMeeting} />
<Button label={love.string.StayInRoom} stopPropagation={false} kind="primary" on:click={stayInMeeting} />
</div>
</svelte:fragment>
</NotificationToast>
@@ -0,0 +1,325 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { formatName, Person } from '@hcengineering/contact'
import { Avatar, getPersonByPersonRefStore } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { Loading } from '@hcengineering/ui'
import MicDisabled from '../icons/MicDisabled.svelte'
import { onDestroy, onMount } from 'svelte'
import {
ChatMessage,
ConnectionQuality,
LocalParticipant,
type LocalTrackPublication,
Participant,
ParticipantEvent,
RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
RoomEvent,
Track,
TrackPublication
} from 'livekit-client'
import BadConnection from '../icons/BadConnection.svelte'
import { lk } from '../../utils'
import Reaction from './Reaction.svelte'
export let _id: string
export let participant: Participant | undefined = undefined
let parent: HTMLDivElement
let mirror: boolean
let activeParticipant: Participant | undefined = undefined
let videoTrack: Track | undefined = undefined
let videoTrackElement: HTMLMediaElement | undefined = undefined
let videoMuted: boolean = true
let microphoneMuted: boolean = true
let isSpeaking: boolean = false
let isBadConnection: boolean = false
$: personByRefStore = getPersonByPersonRefStore([_id as Ref<Person>])
$: user = $personByRefStore.get(_id as Ref<Person>)
$: userName = (user !== undefined ? user.name : activeParticipant?.name) ?? ''
function attachTrack (track: Track): void {
if (parent == null) return
detachCurrentTrack()
videoTrack = track
videoTrackElement = track.attach()
const enabled = activeParticipant?.isCameraEnabled ?? false
videoTrackElement.classList.add('video')
if (!enabled) videoTrackElement.classList.add('hidden')
parent.appendChild(videoTrackElement)
videoMuted = !enabled
}
function detachCurrentTrack (): void {
if (videoTrackElement !== undefined) {
videoTrack?.detach(videoTrackElement)
videoTrackElement.remove()
}
}
function setTrackMuted (value: boolean): void {
if (videoTrackElement !== undefined) {
if (value) {
videoTrackElement.classList.add('hidden')
} else {
videoTrackElement.classList.remove('hidden')
}
}
videoMuted = value
}
function speachHandler (speaking: boolean): void {
isSpeaking = speaking
}
function muteHandler (publication: TrackPublication): void {
if (publication.kind === Track.Kind.Audio) {
microphoneMuted = publication.isMuted
} else if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
setTrackMuted(publication.isMuted)
}
}
function onConnectionQualityChanged (quality: ConnectionQuality): void {
isBadConnection = quality === ConnectionQuality.Lost || quality === ConnectionQuality.Poor
}
function onLocalTrackPublished (publication: LocalTrackPublication): void {
if (publication.track === undefined) return
if (publication.kind === Track.Kind.Audio) {
microphoneMuted = publication.isMuted
} else if (publication.kind === Track.Kind.Video && publication.source !== Track.Source.ScreenShare) {
attachTrack(publication.track)
}
}
function onLocalTrackUnpublished (publication: LocalTrackPublication): void {
if (videoTrack === publication.track) {
detachCurrentTrack()
}
}
function onTrackSubscribed (track: RemoteTrack, publication: RemoteTrackPublication): void {
if (track.kind === Track.Kind.Audio) {
microphoneMuted = track.isMuted
} else if (track.kind === Track.Kind.Video && track.source !== Track.Source.ScreenShare) {
attachTrack(track)
}
}
function onTrackUnsubscribed (track: RemoteTrack, publication: RemoteTrackPublication): void {
if (videoTrack === track) {
detachCurrentTrack()
}
}
function detachParticipant (): void {
if (activeParticipant === undefined) return
activeParticipant.off(ParticipantEvent.TrackMuted, muteHandler)
activeParticipant.off(ParticipantEvent.TrackUnmuted, muteHandler)
activeParticipant.off(ParticipantEvent.IsSpeakingChanged, speachHandler)
if (activeParticipant.isLocal) {
activeParticipant.off(ParticipantEvent.LocalTrackPublished, onLocalTrackPublished)
activeParticipant.off(ParticipantEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
} else {
activeParticipant.off(ParticipantEvent.TrackSubscribed, onTrackSubscribed)
activeParticipant.off(ParticipantEvent.TrackUnsubscribed, onTrackUnsubscribed)
}
activeParticipant = undefined
}
function onChatMessage (message: ChatMessage, participant?: RemoteParticipant | LocalParticipant | undefined): void {
if (activeParticipant !== participant) return
reactions = [
...reactions,
{
id: message.id,
emoji: message.message,
width: parent.offsetWidth,
height: parent.offsetHeight
}
]
}
let reactions: Array<{ id: string, emoji: string, height: number, width: number }> = []
function setParticipant (p: Participant | undefined): void {
if (parent == null) return
if (activeParticipant === p) return
detachParticipant()
detachCurrentTrack()
activeParticipant = p
if (p === undefined) return
for (const publication of p.trackPublications.values()) {
if (
publication.track !== undefined &&
publication.track.kind === Track.Kind.Video &&
publication.track.source !== Track.Source.ScreenShare
) {
attachTrack(publication.track)
break
}
}
mirror = p.isLocal
microphoneMuted = !p.isMicrophoneEnabled
p.on(ParticipantEvent.TrackMuted, muteHandler)
p.on(ParticipantEvent.TrackUnmuted, muteHandler)
p.on(ParticipantEvent.IsSpeakingChanged, speachHandler)
p.on(ParticipantEvent.ConnectionQualityChanged, onConnectionQualityChanged)
if (p.isLocal) {
p.on(ParticipantEvent.LocalTrackPublished, onLocalTrackPublished)
p.on(ParticipantEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
} else {
p.on(ParticipantEvent.TrackSubscribed, onTrackSubscribed)
p.on(ParticipantEvent.TrackUnsubscribed, onTrackUnsubscribed)
}
}
$: setParticipant(participant)
onDestroy(() => {
lk.off(RoomEvent.ChatMessage, onChatMessage)
detachCurrentTrack()
})
onMount(() => {
lk.on(RoomEvent.ChatMessage, onChatMessage)
setParticipant(participant)
})
</script>
<div id={_id} class="parent" class:speach={isSpeaking}>
<div bind:this={parent} class="cover" class:active={!videoMuted} class:mirror={mirror && !videoMuted} />
<div class="ava">
<Avatar size={'full'} name={userName} person={user} showStatus={false} />
</div>
<div class="label" class:withIcon={microphoneMuted || isBadConnection || participant === undefined}>
{#if participant === undefined}<Loading size={'small'} shrink />{/if}
{#if isBadConnection}<BadConnection fill={'var(--bg-negative-default)'} size={'small'} />{/if}
{#if microphoneMuted}<MicDisabled fill={'var(--bg-negative-default)'} size={'small'} />{/if}
<span class="overflow-label">{formatName(userName)}</span>
</div>
{#each reactions as reaction (reaction.id)}
<Reaction
{...reaction}
on:complete={() => {
reactions = reactions.filter((it) => it !== reaction)
}}
/>
{/each}
</div>
<style lang="scss">
:global(.video) {
object-fit: cover;
border-radius: 0.75rem;
width: 100%;
}
:global(.hidden) {
display: none;
}
.cover {
object-fit: cover;
border-radius: 0.75rem;
height: 100%;
width: 100%;
aspect-ratio: 1280 / 720;
display: flex;
justify-content: center;
align-items: center;
&.mirror {
transform: scaleX(-1);
}
&.active + .ava {
display: none;
}
&:not(.active) {
background-color: black;
}
}
.ava {
overflow: hidden;
position: absolute;
top: 50%;
left: 50%;
height: 50%;
aspect-ratio: 1;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.parent {
position: relative;
flex-shrink: 0;
height: max-content;
min-height: 0;
max-height: 100%;
background-color: black;
border-radius: 0.75rem;
.label {
overflow: hidden;
text-overflow: ellipsis;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
top: 0.25rem;
left: 0.25rem;
max-width: 12rem;
font-weight: 500;
font-size: 0.75rem;
line-height: 1rem;
color: var(--white-color);
background-color: rgba(0, 0, 0, 0.5);
border-radius: 0.5rem;
backdrop-filter: blur(3px);
&.withIcon {
padding-left: 0.25rem;
}
}
&.speach::before,
&.speach::after {
position: absolute;
content: '';
inset: 0;
border-radius: 0.75rem;
z-index: 1;
}
&.speach::before {
border: 3px solid var(--border-talk-indication-secondary);
}
&.speach::after {
border: 2px solid var(--border-talk-indication-primary);
}
}
</style>
@@ -0,0 +1,105 @@
<script lang="ts">
import { aiBotSocialIdentityStore } from '@hcengineering/ai-bot-resources'
import ParticipantView from './ParticipantView.svelte'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import { liveKitClient, lk } from '../../utils'
import { infos } from '../../stores'
import { Ref } from '@hcengineering/core'
import { Person } from '@hcengineering/contact'
import { getPersonRefByPersonIdCb } from '@hcengineering/contact-resources'
import { Room as TypeRoom } from '@hcengineering/love'
export let room: Ref<TypeRoom>
const dispatch = createEventDispatcher()
interface ParticipantData {
_id: string
participant: Participant | undefined
isAgent: boolean
}
let aiPersonRef: Ref<Person> | undefined
$: if ($aiBotSocialIdentityStore != null) {
getPersonRefByPersonIdCb($aiBotSocialIdentityStore?._id, (ref) => {
if (ref != null) {
aiPersonRef = ref
}
})
} else {
aiPersonRef = undefined
}
let participants: ParticipantData[] = []
function attachParticipant (participant: Participant): void {
const current = participants.find((p) => p._id === participant.identity)
if (current !== undefined) {
current.participant = participant
participants = participants
return
}
const value: ParticipantData = {
_id: participant.identity,
participant,
isAgent: participant.isAgent
}
participants.push(value)
participants = participants
}
function handleParticipantDisconnected (participant: RemoteParticipant): void {
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1) {
participants.splice(index, 1)
participants = participants
}
}
onMount(async () => {
await liveKitClient.awaitConnect()
for (const participant of lk.remoteParticipants.values()) {
attachParticipant(participant)
}
attachParticipant(lk.localParticipant)
lk.on(RoomEvent.ParticipantConnected, attachParticipant)
lk.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
})
onDestroy(
infos.subscribe((data) => {
for (const info of data) {
if (info.room !== room) continue
const current = participants.find((p) => p._id === info.person)
if (current !== undefined) continue
const value: ParticipantData = {
_id: info.person,
participant: undefined,
isAgent: info.person === aiPersonRef
}
participants.push(value)
}
participants = participants
})
)
onDestroy(() => {
lk.off(RoomEvent.ParticipantConnected, attachParticipant)
lk.off(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
})
function getActiveParticipants (participants: ParticipantData[]): ParticipantData[] {
const result = participants.filter((p) => !p.isAgent || $infos.some(({ person }) => person === p._id))
dispatch('participantsCount', result.length)
return result
}
$: activeParticipants = getActiveParticipants(participants)
</script>
{#each activeParticipants as participant, i (participant._id)}
<div class="video">
<ParticipantView {...participant} />
</div>
{/each}
@@ -0,0 +1,64 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
export let emoji: string = ''
export let width: number = 0
export let height: number = 0
export let duration: number = 2000
const dispatch = createEventDispatcher()
let offsetX: number = 0
let isVisible: boolean = false
let x: number = 0
let size: number = 0
onMount(() => {
offsetX = (Math.random() * width) / 4 - width / 8
size = height / 4
x = width / 2 - size / 2
isVisible = true
setTimeout(() => {
isVisible = false
dispatch('complete')
}, duration)
})
</script>
{#if isVisible}
<div
class="reaction"
style="
left: {x}px;
font-size: {size}px;
--x-offset: {offsetX}px;
--duration: {duration}ms;
"
>
{emoji}
</div>
{/if}
<style lang="scss">
.reaction {
position: absolute;
bottom: 0px;
pointer-events: none;
z-index: 100;
opacity: 1;
animation: floatUp var(--duration) ease-out forwards;
will-change: transform, opacity;
transform: translateZ(0);
}
@keyframes floatUp {
0% {
transform: translate3d(0, 0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate3d(var(--x-offset), -100px, 0) scale(1.5);
opacity: 0;
}
}
</style>
@@ -0,0 +1,102 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte'
import { liveKitClient, lk } from '../../utils'
import {
LocalParticipant,
LocalTrackPublication,
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
RoomEvent,
Track
} from 'livekit-client'
export let hasActiveTrack: boolean = false
export let showLocalTrack: boolean = true
let activeTrack: Track | null = null
let screen: HTMLVideoElement
function trySetActiveTrack (track: Track | undefined): boolean {
if (track === undefined) return false
if (track.kind !== Track.Kind.Video || track.source !== Track.Source.ScreenShare) return false
hasActiveTrack = true
activeTrack = track
track.attach(screen)
return true
}
function clearActiveTrack (track: Track | undefined): void {
if (track !== activeTrack) return
hasActiveTrack = false
activeTrack?.detach()
activeTrack = null
}
function onTrackSubscribed (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant
): void {
trySetActiveTrack(track)
}
function onTrackUnsubscribed (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant
): void {
clearActiveTrack(track)
}
function onLocalTrackPublished (publication: LocalTrackPublication, participant: LocalParticipant): void {
trySetActiveTrack(publication.track)
}
function onLocalTrackUnpublished (publication: LocalTrackPublication, participant: LocalParticipant): void {
clearActiveTrack(publication.track)
}
onMount(async () => {
await liveKitClient.awaitConnect()
lk.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
lk.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
for (const participant of lk.remoteParticipants.values()) {
for (const publication of participant.trackPublications.values()) {
if (trySetActiveTrack(publication.track)) break
}
}
if (showLocalTrack) {
lk.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished)
lk.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
for (const publication of lk.localParticipant.trackPublications.values()) {
if (trySetActiveTrack(publication.track)) break
}
}
})
onDestroy(() => {
activeTrack?.detach(screen)
lk.off(RoomEvent.TrackSubscribed, onTrackSubscribed)
lk.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
if (showLocalTrack) {
lk.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished)
lk.off(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished)
}
})
</script>
<video class="screen" bind:this={screen}></video>
<style lang="scss">
.screen {
object-fit: contain;
max-width: 100%;
max-height: 100%;
height: 100%;
width: 100%;
border-radius: 0.75rem;
}
</style>
@@ -20,10 +20,10 @@
import love from '../../plugin'
import VideoTab from './VideoTab.svelte'
import { isCurrentInstanceConnected } from '../../utils'
import { currentMeetingMinutes, currentRoom } from '../../stores'
import ChatTab from './ChatTab.svelte'
import TranscriptionTab from './TranscriptionTab.svelte'
import { lkSessionConnected } from '../../liveKitClient'
export let widgetState: WidgetState | undefined
export let height: string
@@ -39,7 +39,7 @@
$: room = $currentRoom
$: if (
!$isCurrentInstanceConnected ||
!$lkSessionConnected ||
widgetState?.data?.room === undefined ||
$currentRoom === undefined ||
$currentRoom._id !== widgetState?.data?.room
@@ -18,8 +18,10 @@
import { AppItem } from '@hcengineering/workbench-resources'
import { RoomType } from '@hcengineering/love'
import { currentRoom } from '../../stores'
import { isConnected, isSharingEnabled, isCameraEnabled, isMicEnabled } from '../../utils'
import { isSharingEnabled } from '../../utils'
import { state } from '@hcengineering/media-resources'
import love from '../../plugin'
import { lkSessionConnected } from '../../liveKitClient'
export let label: IntlString
export let icon: Asset | AnySvelteComponent
@@ -27,28 +29,30 @@
export let size: 'small' | 'medium' | 'large' = 'small'
$: allowCam = $currentRoom?.type === RoomType.Video
$: isMicEnabled = $state.microphone?.enabled === true
$: isCamEnabled = $state.camera?.enabled === true
</script>
<AppItem
{label}
icon={$isSharingEnabled
? love.icon.SharingDisabled
: $isConnected && allowCam && !$isCameraEnabled && !$isMicEnabled
: $lkSessionConnected && allowCam && !isCamEnabled && !isMicEnabled
? love.icon.CamDisabled
: $isConnected && !allowCam && $isMicEnabled
: $lkSessionConnected && !allowCam && isMicEnabled
? love.icon.Mic
: !allowCam || (!$isCameraEnabled && $isMicEnabled)
: !allowCam || (!isCamEnabled && isMicEnabled)
? love.icon.Mic
: icon}
{selected}
{size}
kind={$isSharingEnabled
? 'negative'
: $isConnected && $isCameraEnabled
: $lkSessionConnected && isCamEnabled
? 'positive'
: $isConnected && $isMicEnabled
: $lkSessionConnected && isMicEnabled
? 'warning'
: $isConnected && !($isCameraEnabled && $isMicEnabled)
: $lkSessionConnected && !(isCamEnabled && isMicEnabled)
? 'accented'
: 'default'}
on:click
+3 -4
View File
@@ -36,11 +36,10 @@ import {
showRoomSettings,
startTranscription,
stopTranscription,
toggleMic,
toggleVideo,
getMeetingMinutesTitle,
queryMeetingMinutes
} from './utils'
import { toggleMicState, toggleCamState } from '@hcengineering/media-resources'
export { setCustomCreateScreenTracks } from './utils'
@@ -90,8 +89,8 @@ export default async (): Promise<Resources> => ({
MeetingMinutesTitleProvider: getMeetingMinutesTitle
},
actionImpl: {
ToggleMic: toggleMic,
ToggleVideo: toggleVideo,
ToggleMic: toggleMicState,
ToggleVideo: toggleCamState,
StartTranscribing: startTranscription,
StopTranscribing: stopTranscription,
ShowRoomSettings: showRoomSettings,
+147
View File
@@ -0,0 +1,147 @@
import { ConnectionState, type RemoteParticipant, Room as LKRoom, RoomEvent } from 'livekit-client'
import { getMetadata, translate } from '@hcengineering/platform'
import { getSelectedSpeakerId, type MediaSession } from '@hcengineering/media'
import { LoveEvents } from '@hcengineering/love'
import { useMedia } from '@hcengineering/media-resources'
import { get, writable } from 'svelte/store'
import { Analytics } from '@hcengineering/analytics'
import { addNotification, NotificationSeverity } from '@hcengineering/ui'
import { getCurrentLanguage } from '@hcengineering/theme'
import LastParticipantNotification from './components/meeting/LastParticipantNotification.svelte'
import love from './plugin'
import { leaveRoom } from './utils'
import { myInfo, myOffice } from './stores'
export const lkSessionConnected = writable<boolean>(false)
const LAST_PARTICIPANT_NOTIFICATION_DELAY_MS = 2 * 60 * 1000
const AUTO_DISCONNECT_DELAY_MS = 60 * 1000
export function getLiveKitClient (): LiveKitClient {
const wsURL = getMetadata(love.metadata.WebSocketURL)
return new LiveKitClient(wsURL ?? '')
}
export class LiveKitClient {
public readonly liveKitRoom: LKRoom
public currentMediaSession: MediaSession | undefined = undefined
private currentSessionSupportsVideo: boolean = false
private lastParticipantNotificationTimeout: number = -1
private lastParticipantDisconnectTimeout: number = -1
private readonly wsUrl: string
constructor (wsUrl: string) {
this.wsUrl = wsUrl
const lkRoom = new LKRoom({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
screenShareEncoding: {
maxBitrate: 7_000_000,
maxFramerate: 15,
priority: 'high'
}
},
audioCaptureDefaults: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true
},
audioOutput: {
deviceId: getSelectedSpeakerId()
},
videoCaptureDefaults: {
facingMode: 'user',
resolution: {
width: 1280,
height: 720,
frameRate: 30
}
}
})
void lkRoom.prepareConnection(this.wsUrl)
lkRoom.on(RoomEvent.Connected, this.onConnected)
lkRoom.on(RoomEvent.Disconnected, this.onDisconnected)
this.liveKitRoom = lkRoom
}
async connect (token: string, withVideo: boolean): Promise<void> {
this.currentSessionSupportsVideo = withVideo
await this.liveKitRoom.connect(this.wsUrl, token)
}
async disconnect (): Promise<void> {
clearTimeout(this.lastParticipantNotificationTimeout)
const me = this.liveKitRoom.localParticipant
await Promise.all([me.setScreenShareEnabled(false), me.setCameraEnabled(false), me.setMicrophoneEnabled(false)])
await this.liveKitRoom.disconnect()
this.currentSessionSupportsVideo = false
}
async awaitConnect (): Promise<void> {
await new Promise<void>((resolve) => {
if (this.liveKitRoom.state === ConnectionState.Connected) {
resolve()
}
this.liveKitRoom.once(RoomEvent.Connected, () => {
resolve()
})
})
}
onConnected = (): void => {
const session = useMedia({
state: {
camera: this.currentSessionSupportsVideo ? { enabled: false } : undefined,
microphone: { enabled: false }
},
autoDestroy: false
})
lkSessionConnected.set(true)
this.currentMediaSession = session
this.liveKitRoom.on(RoomEvent.ParticipantConnected, this.onParticipantConnected)
this.liveKitRoom.on(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected)
}
onDisconnected = (): void => {
this.currentMediaSession?.close()
this.currentMediaSession?.removeAllListeners()
this.currentMediaSession = undefined
lkSessionConnected.set(false)
this.liveKitRoom.off(RoomEvent.ParticipantConnected, this.onParticipantConnected)
this.liveKitRoom.off(RoomEvent.ParticipantDisconnected, this.onParticipantDisconnected)
Analytics.handleEvent(LoveEvents.DisconnectedFromRoom)
}
onParticipantConnected = (participant: RemoteParticipant): void => {
clearTimeout(this.lastParticipantDisconnectTimeout)
clearTimeout(this.lastParticipantNotificationTimeout)
}
onParticipantDisconnected = (participant: RemoteParticipant): void => {
if (this.liveKitRoom.remoteParticipants.size === 0) {
clearTimeout(this.lastParticipantDisconnectTimeout)
clearTimeout(this.lastParticipantNotificationTimeout)
this.lastParticipantNotificationTimeout = window.setTimeout(() => {
void this.showLastParticipantNotification()
}, LAST_PARTICIPANT_NOTIFICATION_DELAY_MS)
}
}
async showLastParticipantNotification (): Promise<void> {
addNotification(
await translate(love.string.MeetingEmptyTitle, {}, getCurrentLanguage()),
await translate(love.string.MeetingEmptyMessage, {}, getCurrentLanguage()),
LastParticipantNotification,
undefined,
NotificationSeverity.Info,
'love'
)
this.lastParticipantDisconnectTimeout = window.setTimeout(() => {
void leaveRoom(get(myInfo), get(myOffice))
}, AUTO_DISCONNECT_DELAY_MS)
}
}
+4 -1
View File
@@ -105,6 +105,9 @@ export default mergeIds(loveId, love, {
Sharing: '' as IntlString,
Browser: '' as IntlString,
Monitor: '' as IntlString,
Window: '' as IntlString
Window: '' as IntlString,
MeetingEmptyTitle: '' as IntlString,
MeetingEmptyMessage: '' as IntlString,
StayInRoom: '' as IntlString
}
})
-2
View File
@@ -13,7 +13,6 @@ import {
type ParticipantInfo,
type Room
} from '@hcengineering/love'
import { type MediaSession } from '@hcengineering/media'
import { createQuery, onClient } from '@hcengineering/presentation'
import { derived, get, writable } from 'svelte/store'
@@ -60,7 +59,6 @@ export let $myPreferences: DevicesPreference | undefined
export const currentMeetingMinutes = writable<MeetingMinutes | undefined>(undefined)
export const selectedRoomPlace = writable<{ _id: Ref<Room>, x: number, y: number } | undefined>(undefined)
export const currentSession = writable<MediaSession | undefined>(undefined)
async function filterParticipantInfo (value: ParticipantInfo[]): Promise<ParticipantInfo[]> {
const map = new Map<string, ParticipantInfo>()
+68 -217
View File
@@ -40,8 +40,7 @@ import {
RoomType,
TranscriptionStatus
} from '@hcengineering/love'
import { getSelectedSpeakerId } from '@hcengineering/media'
import { getSelectedCam, getSelectedMic, getSelectedSpeaker, useMedia } from '@hcengineering/media-resources'
import { getSelectedCam, getSelectedMic, getSelectedSpeaker } from '@hcengineering/media-resources'
import { getEmbeddedLabel, getMetadata, getResource, type IntlString } from '@hcengineering/platform'
import presentation, {
copyTextToClipboard,
@@ -65,16 +64,14 @@ import { isKrispNoiseFilterSupported, KrispNoiseFilter } from '@livekit/krisp-no
import { BackgroundBlur, type BackgroundOptions, type ProcessorWrapper } from '@livekit/track-processors'
import {
type AudioCaptureOptions,
ConnectionState,
Room as LKRoom,
LocalAudioTrack,
type LocalTrack,
type LocalTrackPublication,
LocalVideoTrack,
type Participant,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
type Room as LKRoom,
RoomEvent,
type ScreenShareCaptureOptions,
Track,
@@ -83,18 +80,11 @@ import {
import { get, writable } from 'svelte/store'
import { getPersonByPersonRef } from '@hcengineering/contact-resources'
import { sendMessage } from './broadcast'
import MeetingMinutesSearchItem from './components/MeetingMinutesSearchItem.svelte'
import RoomSettingsPopup from './components/RoomSettingsPopup.svelte'
import love from './plugin'
import {
$myPreferences,
currentMeetingMinutes,
currentRoom,
currentSession,
myOffice,
selectedRoomPlace
} from './stores'
import { $myPreferences, currentMeetingMinutes, currentRoom, myOffice, selectedRoomPlace } from './stores'
import { getLiveKitClient } from './liveKitClient'
export async function getToken (
roomName: string,
@@ -128,66 +118,21 @@ function getTokenRoomName (roomName: string, roomId: Ref<Room>): string {
return `${currentWorkspace.uuid}_${roomName}_${roomId}`
}
export const lk: LKRoom = new LKRoom({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
screenShareEncoding: {
maxBitrate: 7_000_000,
maxFramerate: 15,
priority: 'high'
}
},
audioCaptureDefaults: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true
},
audioOutput: {
deviceId: getSelectedSpeakerId()
},
videoCaptureDefaults: {
facingMode: 'user',
resolution: {
width: 1280,
height: 720,
frameRate: 30
}
}
})
export const liveKitClient = getLiveKitClient()
export const lk: LKRoom = liveKitClient.liveKitRoom
export function setCustomCreateScreenTracks (value: () => Promise<Array<LocalTrack<Track.Kind>>>): void {
lk.localParticipant.createScreenTracks = value
}
async function prepare (): Promise<void> {
const wsURL = getMetadata(love.metadata.WebSocketURL)
if (wsURL !== undefined) {
await lk.prepareConnection(wsURL)
}
}
void prepare()
export const isConnected = writable<boolean>(false)
export const isCurrentInstanceConnected = writable<boolean>(false)
export let $isCurrentInstanceConnected: boolean = false
isCurrentInstanceConnected.subscribe((value) => {
$isCurrentInstanceConnected = value
})
export const screenSharing = writable<boolean>(false)
export const isRecording = writable<boolean>(false)
export const isTranscription = writable<boolean>(false)
export const isRecordingAvailable = writable<boolean>(false)
export const isMicEnabled = writable<boolean>(false)
export const isCameraEnabled = writable<boolean>(false)
export const isSharingEnabled = writable<boolean>(false)
export const isFullScreen = writable<boolean>(false)
export const isShareWithSound = writable<boolean>(false)
export const currentRoomAudioLevels = writable<Map<Ref<Person>, number>>(new Map())
function handleTrackSubscribed (
track: RemoteTrack,
publication: RemoteTrackPublication,
@@ -210,7 +155,7 @@ function handleTrackUnsubscribed (
lk.on(RoomEvent.TrackSubscribed, handleTrackSubscribed)
lk.on(RoomEvent.LocalTrackPublished, (pub, part) => {
const session = get(currentSession)
const session = liveKitClient.currentMediaSession
const track = pub.track?.mediaStreamTrack
const deviceId = track?.getSettings().deviceId
if (pub.track?.kind === Track.Kind.Video) {
@@ -218,62 +163,47 @@ lk.on(RoomEvent.LocalTrackPublished, (pub, part) => {
session?.setFeature('sharing', { enabled: true, track, deviceId })
screenSharing.set(true)
isSharingEnabled.set(true)
sendMessage({ type: 'share', value: true })
} else {
session?.setCamera({ enabled: true, track, deviceId })
isCameraEnabled.set(true)
sendMessage({ type: 'cam', value: true })
}
} else if (pub.track?.kind === Track.Kind.Audio) {
session?.setMicrophone({ enabled: true, track, deviceId })
isMicEnabled.set(!pub.track?.isMuted)
sendMessage({ type: 'mic', value: !pub.track?.isMuted })
}
})
lk.on(RoomEvent.LocalTrackUnpublished, (pub, part) => {
const session = get(currentSession)
const session = liveKitClient.currentMediaSession
if (pub.track?.kind === Track.Kind.Video) {
if (pub.track.source === Track.Source.ScreenShare) {
session?.setFeature('sharing', { enabled: false })
screenSharing.set(false)
isSharingEnabled.set(false)
sendMessage({ type: 'share', value: false })
} else {
session?.setCamera({ enabled: false })
isCameraEnabled.set(false)
sendMessage({ type: 'cam', value: false })
}
} else if (pub.track?.kind === Track.Kind.Audio) {
session?.setMicrophone({ enabled: false })
isMicEnabled.set(false)
sendMessage({ type: 'mic', value: false })
}
})
lk.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed)
lk.on(RoomEvent.TrackMuted, (pub, participant) => {
if (participant.isLocal) {
const session = get(currentSession)
const session = liveKitClient.currentMediaSession
if (pub.track?.kind === Track.Kind.Video) {
if (pub.track.source === Track.Source.ScreenShare) {
session?.setFeature('sharing', { enabled: false })
screenSharing.set(false)
isSharingEnabled.set(false)
sendMessage({ type: 'share', value: false })
} else {
session?.setCamera({ enabled: false })
isCameraEnabled.set(false)
sendMessage({ type: 'cam', value: false })
}
} else if (pub.track?.kind === Track.Kind.Audio) {
session?.setMicrophone({ enabled: false })
isMicEnabled.set(false)
sendMessage({ type: 'mic', value: false })
}
}
})
lk.on(RoomEvent.TrackUnmuted, (pub, participant) => {
if (participant.isLocal) {
const session = get(currentSession)
const session = liveKitClient.currentMediaSession
const track = pub.track?.mediaStreamTrack
const deviceId = track?.getSettings().deviceId
if (pub.track?.kind === Track.Kind.Video) {
@@ -281,16 +211,11 @@ lk.on(RoomEvent.TrackUnmuted, (pub, participant) => {
session?.setFeature('sharing', { enabled: true })
screenSharing.set(true)
isSharingEnabled.set(true)
sendMessage({ type: 'share', value: true })
} else {
session?.setCamera({ enabled: true, track, deviceId })
isCameraEnabled.set(true)
sendMessage({ type: 'cam', value: true })
}
} else if (pub.track?.kind === Track.Kind.Audio) {
session?.setMicrophone({ enabled: true, track, deviceId })
isMicEnabled.set(true)
sendMessage({ type: 'mic', value: true })
}
}
})
@@ -381,7 +306,6 @@ lk.on(RoomEvent.LocalTrackPublished, (pub) => {
if (pub.track?.kind === Track.Kind.Video && pub.track.source === Track.Source.ScreenShare) {
screenSharing.set(true)
isSharingEnabled.set(true)
sendMessage({ type: 'share', value: true })
}
if (pub.source === Track.Source.Microphone) {
@@ -397,7 +321,6 @@ lk.on(RoomEvent.LocalTrackUnpublished, (pub) => {
if (pub.track.source === Track.Source.ScreenShare) {
screenSharing.set(false)
isSharingEnabled.set(false)
sendMessage({ type: 'share', value: false })
} else if (pub.track.source === Track.Source.Camera) {
if (localVideo !== undefined) {
localVideo = undefined
@@ -418,30 +341,18 @@ lk.on(RoomEvent.RoomMetadataChanged, (metadata) => {
}
})
lk.on(RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {
currentRoomAudioLevels.set(new Map(speakers.map((it) => [it.identity as Ref<Person>, it.audioLevel])))
})
lk.on(RoomEvent.Connected, () => {
const current = get(currentRoom)
const session = liveKitClient.currentMediaSession
const session = useMedia({
state: {
camera: current?.type === RoomType.Video ? { enabled: false } : undefined,
microphone: { enabled: false }
},
autoDestroy: false
})
session.on('camera', (enabled) => {
session?.on('camera', (enabled) => {
void setCam(enabled)
})
session.on('microphone', (enabled) => {
session?.on('microphone', (enabled) => {
void setMic(enabled)
})
session.on('selected-camera', (deviceId) => {
session?.on('selected-camera', (deviceId) => {
if (deviceId === null) {
if (lk.localParticipant.isCameraEnabled) {
void setCam(false)
@@ -455,7 +366,7 @@ lk.on(RoomEvent.Connected, () => {
}
})
session.on('selected-microphone', (deviceId) => {
session?.on('selected-microphone', (deviceId) => {
if (lk.localParticipant.isMicrophoneEnabled) {
void lk.switchActiveDevice('audioinput', deviceId)
} else {
@@ -463,35 +374,19 @@ lk.on(RoomEvent.Connected, () => {
}
})
session.on('selected-speaker', (deviceId) => {
session?.on('selected-speaker', (deviceId) => {
void lk.switchActiveDevice('audiooutput', deviceId)
})
session.on('feature', (feature, enabled) => {
session?.on('feature', (feature, enabled) => {
if (feature === 'sharing') {
void setShare(enabled)
}
})
currentSession.set(session)
isConnected.set(true)
currentRoomAudioLevels.set(new Map())
sendMessage({ type: 'connect', value: true })
isCurrentInstanceConnected.set(true)
isRecording.set(lk.isRecording)
void initRoom()
Analytics.handleEvent(LoveEvents.ConnectedToRoom)
})
lk.on(RoomEvent.Disconnected, () => {
const session = get(currentSession)
session?.close()
session?.removeAllListeners()
currentSession.set(undefined)
isConnected.set(false)
sendMessage({ type: 'connect', value: true })
isCurrentInstanceConnected.set(false)
Analytics.handleEvent(LoveEvents.DisconnectedFromRoom)
})
async function initRoom (): Promise<void> {
const room = get(currentRoom)
@@ -544,47 +439,19 @@ async function withRetries (fn: () => Promise<void>, retries: number, delay: num
}
}
async function connect (name: string, room: Room, _id: string): Promise<void> {
const wsURL = getLiveKitEndpoint()
if (getCurrentAccount().role === AccountRole.ReadOnlyGuest) {
return
}
const token = await getToken(room.name, room._id, _id, name)
await lk.connect(wsURL, token)
sendMessage({ type: 'connect', value: true })
}
export async function awaitConnect (): Promise<void> {
await new Promise<void>((resolve) => {
if (lk.state === ConnectionState.Connected) {
resolve()
}
lk.once(RoomEvent.Connected, () => {
resolve()
})
})
}
export async function disconnect (): Promise<void> {
const me = lk.localParticipant
await Promise.all([me.setScreenShareEnabled(false), me.setCameraEnabled(false), me.setMicrophoneEnabled(false)])
await lk.disconnect()
await liveKitClient.disconnect()
screenSharing.set(false)
isMicEnabled.set(false)
isCameraEnabled.set(false)
isSharingEnabled.set(false)
currentRoomAudioLevels.set(new Map())
sendMessage({ type: 'mic', value: false })
sendMessage({ type: 'cam', value: false })
sendMessage({ type: 'share', value: false })
sendMessage({ type: 'connect', value: false })
get(currentSession)?.close()
}
export async function leaveRoom (ownInfo: ParticipantInfo | undefined, ownOffice: Office | undefined): Promise<void> {
const me = lk.localParticipant
await Promise.all([me.setScreenShareEnabled(false), me.setCameraEnabled(false), me.setMicrophoneEnabled(false)])
try {
await Promise.all([me.setScreenShareEnabled(false), me.setCameraEnabled(false), me.setMicrophoneEnabled(false)])
} catch (err: any) {
console.error(err)
}
if (ownInfo !== undefined) {
const client = getClient()
if (ownOffice !== undefined && ownInfo.room !== ownOffice._id) {
@@ -626,67 +493,55 @@ function isRoomOpened (room: Room): boolean {
return false
}
export async function setCam (value: boolean): Promise<void> {
async function setCam (value: boolean): Promise<void> {
if (value && get(currentRoom)?.type !== RoomType.Video) return
if ($isCurrentInstanceConnected) {
try {
const opt: VideoCaptureOptions = {}
const selectedDevice = await getSelectedCam()
if (selectedDevice !== null && selectedDevice !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: selectedDevice.deviceId }
await lk.switchActiveDevice('videoinput', selectedDevice.deviceId)
}
await lk.localParticipant.setCameraEnabled(value, opt)
} catch (err) {
console.error(err)
try {
const opt: VideoCaptureOptions = {}
const selectedDevice = await getSelectedCam()
if (selectedDevice !== null && selectedDevice !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: selectedDevice.deviceId }
await lk.switchActiveDevice('videoinput', selectedDevice.deviceId)
}
} else {
sendMessage({ type: 'set_cam', value })
await lk.localParticipant.setCameraEnabled(value, opt)
} catch (err) {
console.error(err)
}
}
export async function setMic (value: boolean): Promise<void> {
if ($isCurrentInstanceConnected) {
try {
const speaker = await getSelectedSpeaker()
if (speaker !== null && speaker !== undefined) {
await lk.switchActiveDevice('audiooutput', speaker.deviceId)
}
} catch (err) {
console.error(err)
async function setMic (value: boolean): Promise<void> {
try {
const speaker = await getSelectedSpeaker()
if (speaker !== null && speaker !== undefined) {
await lk.switchActiveDevice('audiooutput', speaker.deviceId)
}
try {
const opt: AudioCaptureOptions = {}
const selectedDevice = await getSelectedMic()
if (selectedDevice !== null && selectedDevice !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: selectedDevice.deviceId }
}
await lk.localParticipant.setMicrophoneEnabled(value, opt)
} catch (err) {
console.error(err)
} catch (err) {
console.error(err)
}
try {
const opt: AudioCaptureOptions = {}
const selectedDevice = await getSelectedMic()
if (selectedDevice !== null && selectedDevice !== undefined) {
// We need to use exact device id to avoid issues with Firefox
// that can switch to another device when using just deviceId
opt.deviceId = { exact: selectedDevice.deviceId }
}
} else {
sendMessage({ type: 'set_mic', value })
await lk.localParticipant.setMicrophoneEnabled(value, opt)
} catch (err) {
console.error(err)
}
}
export async function setShare (value: boolean, withAudio: boolean = false): Promise<void> {
if ($isCurrentInstanceConnected) {
try {
const options: ScreenShareCaptureOptions = {}
if (withAudio) {
options.audio = true
}
await lk.localParticipant.setScreenShareEnabled(value, options)
} catch (err) {
console.error(err)
try {
const options: ScreenShareCaptureOptions = {}
if (withAudio) {
options.audio = true
}
} else {
sendMessage({ type: 'set_share', value })
await lk.localParticipant.setScreenShareEnabled(value, options)
} catch (err) {
console.error(err)
}
}
@@ -740,8 +595,8 @@ async function moveToRoom (
}
}
async function connectLK (currentPerson: Person, room: Room): Promise<void> {
await connect(currentPerson.name, room, currentPerson._id)
async function connectLK (token: string, room: Room): Promise<void> {
await liveKitClient.connect(token, room.type === RoomType.Video)
await Promise.all([
setMic($myPreferences?.micEnabled ?? lk.remoteParticipants.size < 16),
setCam(room.type === RoomType.Video && ($myPreferences?.camEnabled ?? true))
@@ -781,13 +636,17 @@ export async function connectRoom (
currentPerson: Person,
room: Room
): Promise<void> {
await disconnect()
await moveToRoom(x, y, currentInfo, currentPerson, room, getMetadata(presentation.metadata.SessionId) ?? null)
selectedRoomPlace.set(undefined)
if (getCurrentAccount().role === AccountRole.ReadOnlyGuest) {
return
}
await disconnect()
const token = await getToken(room.name, room._id, currentPerson._id, currentPerson.name)
try {
await withRetries(
async () => {
await connectLK(currentPerson, room)
await connectLK(token, room)
},
3,
1000
@@ -949,14 +808,6 @@ export async function invite (person: Ref<Person>, room: Ref<Room> | undefined):
})
}
export async function toggleMic (): Promise<void> {
await setMic(!get(isMicEnabled))
}
export async function toggleVideo (): Promise<void> {
await setCam(!get(isCameraEnabled))
}
export async function record (room: Room): Promise<void> {
try {
const endpoint = getLoveEndpoint()
+2 -1
View File
@@ -107,7 +107,8 @@ const love = plugin(loveId, {
MeetingMinutes: '' as Asset
},
sound: {
Knock: '' as Asset
Knock: '' as Asset,
MeetingEndNotification: '' as Asset
},
metadata: {
WebSocketURL: '' as Metadata<string>,