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
@@ -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