3}
class:hidden={loading}
class:mobile={$deviceInfo.isMobile}
>
-
-
{
- updateStyle(evt.detail, withScreenSharing)
- }}
- />
+ {#if withScreenSharing && !$deviceInfo.isMobile}
+
+ {/if}
+
{#if $currentRoom}
@@ -162,7 +157,6 @@
.room-container {
display: flex;
justify-content: center;
- padding: 1rem;
width: 100%;
height: 100%;
min-width: 0;
@@ -173,6 +167,7 @@
display: flex;
justify-content: center;
align-items: center;
+ padding: 0.5rem;
max-height: 100%;
min-height: 0;
width: 100%;
@@ -190,55 +185,59 @@
&:not(.sharing) {
gap: 0;
- .videoGrid {
- display: grid;
- grid-auto-rows: 1fr;
- justify-content: center;
- align-items: center;
- gap: 1rem;
- max-height: 100%;
- max-width: 100%;
+ .participantsPane {
+ flex: 1;
+ --participants-gap: 1rem;
}
.screenContainer {
display: none;
}
}
&.sharing {
- gap: 1rem;
+ gap: 0;
- .videoGrid {
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- margin: 0.5rem 0;
- padding: 0 0.5rem;
- width: 15rem;
- min-width: 15rem;
- min-height: 0;
- max-width: 15rem;
+ .screenContainer {
+ flex: 1 1 auto;
+ min-width: 0;
}
- }
- &.many {
- padding: 0.5rem;
-
- &:not(.sharing) .videoGrid,
- &.sharing {
- gap: 0.5rem;
+ .participantsPane {
+ flex: 0 0 auto;
+ width: clamp(14rem, 22vw, 18rem);
+ max-width: clamp(14rem, 22vw, 18rem);
+ min-width: 12rem;
+ height: 100%;
+ overflow-y: auto;
+ --participants-gap: var(--spacing-0_5);
}
}
-
&.mobile {
padding: var(--spacing-0_5);
- &:not(.sharing) .videoGrid,
- &.sharing {
- gap: var(--spacing-0_5);
+ .participantsPane {
+ padding: var(--spacing-0_25);
+ --participants-gap: var(--spacing-0_5);
}
}
}
.hidden {
display: none;
}
+
+ .participantsPane {
+ display: flex;
+ flex: 1 1 auto;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+ padding: 0.5rem;
+ }
+
+ .participantsPane :global(.participants-grid) {
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ }
diff --git a/plugins/love-resources/src/components/VideoPopup.svelte b/plugins/love-resources/src/components/VideoPopup.svelte
index d264f5b6ccf..4f737726a2a 100644
--- a/plugins/love-resources/src/components/VideoPopup.svelte
+++ b/plugins/love-resources/src/components/VideoPopup.svelte
@@ -15,8 +15,7 @@
@@ -101,20 +90,20 @@
}
}
- .videoGrid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- grid-auto-flow: row;
- gap: var(--spacing-1);
- }
- @container videoPopupСontainer (max-width: 60rem) {
- .videoGrid {
- grid-template-columns: repeat(2, 1fr);
- }
+ .participantsPane {
+ display: flex;
+ flex: 1 1 auto;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+ padding: 0.5rem;
}
- @container videoPopupСontainer (max-width: 30rem) {
- .videoGrid {
- grid-template-columns: 1fr;
- }
+ .participantsPane :global(.participants-grid) {
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ --participants-gap: var(--spacing-1);
}
diff --git a/plugins/love-resources/src/components/meeting/ControlBarContainer.svelte b/plugins/love-resources/src/components/meeting/ControlBarContainer.svelte
index 2fb9db5f2a3..ed74e73a7c5 100644
--- a/plugins/love-resources/src/components/meeting/ControlBarContainer.svelte
+++ b/plugins/love-resources/src/components/meeting/ControlBarContainer.svelte
@@ -118,7 +118,7 @@
@container (max-width: 440px) {
.bar[data-size='small'] .row {
justify-content: center;
- gap: var(--g);
+ gap: 0.2rem;
}
.bar[data-size='small'] .left,
.bar[data-size='small'] .center,
diff --git a/plugins/love-resources/src/components/meeting/ControlExt.svelte b/plugins/love-resources/src/components/meeting/ControlExt.svelte
index 7a990fc4d3a..b3bdfd64a2a 100644
--- a/plugins/love-resources/src/components/meeting/ControlExt.svelte
+++ b/plugins/love-resources/src/components/meeting/ControlExt.svelte
@@ -91,7 +91,7 @@
if (widget === undefined) return
if (!isMeetingWidgetCreated) {
- createMeetingWidget(widget, room, meetingSessionConnected)
+ createMeetingWidget(widget, room)
}
} else {
if (isMeetingWidgetCreated) {
diff --git a/plugins/love-resources/src/components/meeting/ParticipantsListView.svelte b/plugins/love-resources/src/components/meeting/ParticipantsListView.svelte
index a916c120feb..c3d83402184 100644
--- a/plugins/love-resources/src/components/meeting/ParticipantsListView.svelte
+++ b/plugins/love-resources/src/components/meeting/ParticipantsListView.svelte
@@ -2,7 +2,7 @@
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 { createEventDispatcher, onDestroy, onMount, tick, afterUpdate } from 'svelte'
import { liveKitClient, lk } from '../../utils'
import { infos } from '../../stores'
import { Ref } from '@hcengineering/core'
@@ -20,6 +20,10 @@
isAgent: boolean
}
+ const MIN_TILE_WIDTH = 192
+ const DEFAULT_GRID_GAP = 16
+ const TILE_ASPECT_RATIO = 16 / 9
+
let aiPersonRef: Ref
| undefined
$: if ($aiBotSocialIdentityStore != null) {
getPersonRefByPersonIdCb($aiBotSocialIdentityStore?._id, (ref) => {
@@ -32,12 +36,19 @@
}
let participants: ParticipantData[] = []
+ let activeParticipants: ParticipantData[] = []
+
+ let container: HTMLDivElement | undefined
+ let columns = 1
+ let gridStyle = `--participant-min-width: ${MIN_TILE_WIDTH}px; --participant-columns: ${columns}; --participant-tile-width: ${MIN_TILE_WIDTH}px; --participant-grid-width: ${MIN_TILE_WIDTH}px;`
+
+ let resizeObserver: ResizeObserver | undefined
function attachParticipant (participant: Participant): void {
const current = participants.find((p) => p._id === participant.identity)
if (current !== undefined) {
current.participant = participant
- participants = participants
+ participants = [...participants]
return
}
const value: ParticipantData = {
@@ -45,16 +56,109 @@
participant,
isAgent: participant.isAgent
}
- participants.push(value)
- participants = participants
+ participants = [...participants, value]
}
function handleParticipantDisconnected (participant: RemoteParticipant): void {
const index = participants.findIndex((p) => p._id === participant.identity)
if (index !== -1) {
participants.splice(index, 1)
- participants = participants
+ participants = [...participants]
+ }
+ }
+
+ function getGapPx (): number {
+ if (container == null) return DEFAULT_GRID_GAP
+ const styles = getComputedStyle(container)
+ const gap = parseFloat(styles.columnGap)
+ return Number.isFinite(gap) ? gap : DEFAULT_GRID_GAP
+ }
+
+ const round = (value: number): number => (Number.isFinite(value) ? Number(value.toFixed(2)) : 0)
+
+ function updateLayout (): void {
+ if (container == null) return
+
+ const count = activeParticipants.length
+ const width = container.clientWidth
+ const height = container.clientHeight
+ const gap = getGapPx()
+
+ if (count === 0 || width <= 0) {
+ columns = 1
+ gridStyle = `--participant-min-width: ${MIN_TILE_WIDTH}px; --participant-columns: ${columns}; --participant-tile-width: ${MIN_TILE_WIDTH}px; --participant-grid-width: ${MIN_TILE_WIDTH}px;`
+ return
+ }
+
+ const maxColumns = Math.max(1, Math.min(count, Math.floor((width + gap) / (MIN_TILE_WIDTH + gap)) || 1))
+
+ let bestCols = 0
+ let bestTileWidth = MIN_TILE_WIDTH
+ let bestArea = -1
+
+ for (let cols = 1; cols <= maxColumns; cols++) {
+ const rows = Math.ceil(count / cols)
+ const totalGapWidth = gap * Math.max(cols - 1, 0)
+ const usableWidth = width - totalGapWidth
+ if (usableWidth <= 0) continue
+
+ const widthLimited = usableWidth / cols
+ const minAllowedWidth = Math.min(MIN_TILE_WIDTH, width)
+ if (widthLimited < minAllowedWidth) continue
+
+ let tileWidth = widthLimited
+ if (height > 0) {
+ const totalGapHeight = gap * Math.max(rows - 1, 0)
+ const usableHeight = height - totalGapHeight
+ if (usableHeight > 0) {
+ const heightLimited = (usableHeight / rows) * TILE_ASPECT_RATIO
+ tileWidth = Math.min(tileWidth, heightLimited)
+ }
+ }
+
+ if (tileWidth < Math.min(MIN_TILE_WIDTH, width)) continue
+
+ const tileHeight = tileWidth / TILE_ASPECT_RATIO
+ const totalHeight = tileHeight * rows + gap * Math.max(rows - 1, 0)
+ if (height > 0 && totalHeight > height + 0.5) continue
+
+ const area = tileWidth * tileHeight
+
+ if (area > bestArea + 0.1 || (Math.abs(area - bestArea) < 0.1 && cols < bestCols)) {
+ bestArea = area
+ bestCols = cols
+ bestTileWidth = tileWidth
+ }
+ }
+
+ if (bestArea >= 0 && bestCols > 0) {
+ const roundedTile = round(Math.min(bestTileWidth, width))
+ const gridWidth = round(Math.min(width, roundedTile * bestCols + gap * Math.max(bestCols - 1, 0)))
+ columns = bestCols
+ gridStyle = `--participant-min-width: ${MIN_TILE_WIDTH}px; --participant-columns: ${bestCols}; --participant-tile-width: ${roundedTile}px; --participant-grid-width: ${gridWidth}px;`
+ return
+ }
+
+ let fallbackCols = Math.max(1, Math.min(count, maxColumns))
+ let fallbackTileWidth = 0
+ while (fallbackCols > 1) {
+ const usableWidth = width - gap * Math.max(fallbackCols - 1, 0)
+ const candidateWidth = usableWidth / fallbackCols
+ if (candidateWidth >= Math.min(MIN_TILE_WIDTH, width)) {
+ fallbackTileWidth = candidateWidth
+ break
+ }
+ fallbackCols--
+ }
+ if (fallbackTileWidth === 0) {
+ fallbackCols = 1
+ fallbackTileWidth = Math.min(width, Math.max(MIN_TILE_WIDTH, width))
}
+
+ const roundedTile = round(Math.min(fallbackTileWidth, width))
+ const gridWidth = round(Math.min(width, roundedTile * fallbackCols + gap * Math.max(fallbackCols - 1, 0)))
+ columns = fallbackCols
+ gridStyle = `--participant-min-width: ${MIN_TILE_WIDTH}px; --participant-columns: ${fallbackCols}; --participant-tile-width: ${roundedTile}px; --participant-grid-width: ${gridWidth}px;`
}
onMount(async () => {
@@ -67,6 +171,18 @@
lk.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
})
+ onMount(async () => {
+ if (typeof ResizeObserver === 'undefined') return
+ await tick()
+ if (container == null) return
+
+ resizeObserver = new ResizeObserver(() => {
+ updateLayout()
+ })
+ resizeObserver.observe(container)
+ updateLayout()
+ })
+
onDestroy(
infos.subscribe((data) => {
for (const info of data) {
@@ -80,13 +196,14 @@
}
participants.push(value)
}
- participants = participants
+ participants = [...participants]
})
)
onDestroy(() => {
lk.off(RoomEvent.ParticipantConnected, attachParticipant)
lk.off(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected)
+ resizeObserver?.disconnect()
})
function getActiveParticipants (participants: ParticipantData[]): ParticipantData[] {
@@ -96,10 +213,47 @@
}
$: activeParticipants = getActiveParticipants(participants)
+
+ afterUpdate(() => {
+ updateLayout()
+ })
-{#each activeParticipants as participant, i (participant._id)}
-
-{/each}
+
+ {#each activeParticipants as participant (participant._id)}
+
+ {/each}
+
+
+
diff --git a/plugins/love-resources/src/components/meeting/widget/VideoTab.svelte b/plugins/love-resources/src/components/meeting/widget/VideoTab.svelte
index c2a5b89e0b0..40a9516ff50 100644
--- a/plugins/love-resources/src/components/meeting/widget/VideoTab.svelte
+++ b/plugins/love-resources/src/components/meeting/widget/VideoTab.svelte
@@ -33,5 +33,6 @@
align-items: center;
background-color: var(--theme-statusbar-color);
overflow: hidden;
+ height: 100%;
}
diff --git a/plugins/love-resources/src/types.ts b/plugins/love-resources/src/types.ts
index be32a6707d3..3bf49f3cd68 100644
--- a/plugins/love-resources/src/types.ts
+++ b/plugins/love-resources/src/types.ts
@@ -1,4 +1,3 @@
-import { type DefSeparators } from '@hcengineering/ui'
import { type RoomLanguage } from '@hcengineering/love'
export interface ResizeInitParams {
@@ -41,8 +40,6 @@ export interface RGBAColor {
export const shadowNormal: RGBAColor = { r: 81, g: 144, b: 236, a: 1 }
export const shadowError: RGBAColor = { r: 249, g: 110, b: 80, a: 1 }
-export const loveSeparators: DefSeparators = [{ minSize: 17.5, size: 25, maxSize: 30, float: 'navigator' }, null]
-
export const languagesDisplayData: {
[key in RoomLanguage]: { emoji: string, label: string }
} = {
diff --git a/plugins/love-resources/src/utils.ts b/plugins/love-resources/src/utils.ts
index 1870183ecb9..8987719fc77 100644
--- a/plugins/love-resources/src/utils.ts
+++ b/plugins/love-resources/src/utils.ts
@@ -430,18 +430,14 @@ export function isTranscriptionAllowed (): boolean {
return url !== ''
}
-export function createMeetingWidget (widget: Widget, room: Ref, video: boolean): void {
+export function createMeetingWidget (widget: Widget, room: Ref): void {
const tabs: WidgetTab[] = [
- ...(video
- ? [
- {
- id: 'video',
- label: love.string.Video,
- icon: love.icon.Cam,
- readonly: true
- }
- ]
- : []),
+ {
+ id: 'video',
+ label: love.string.Video,
+ icon: love.icon.Cam,
+ readonly: true
+ },
{
id: 'chat',
label: chunter.string.Chat,