diff --git a/plugins/love-resources/src/components/Room.svelte b/plugins/love-resources/src/components/Room.svelte index ff7c0bff190..792666798cd 100644 --- a/plugins/love-resources/src/components/Room.svelte +++ b/plugins/love-resources/src/components/Room.svelte @@ -16,12 +16,12 @@ import { ActionContext } from '@hcengineering/presentation' import { Room as TypeRoom } from '@hcengineering/love' import { getMetadata } from '@hcengineering/platform' - import { Label, Loading, deviceOptionsStore as deviceInfo } from '@hcengineering/ui' + import { Label, Loading, Separator, defineSeparators, deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { onDestroy, onMount } from 'svelte' import love from '../plugin' import { waitForOfficeLoaded, currentRoom } from '../stores' - import { isFullScreen, lk } from '../utils' + import { isFullScreen } from '../utils' import ControlBar from './meeting/ControlBar.svelte' import ParticipantsListView from './meeting/ParticipantsListView.svelte' import ScreenSharingView from './meeting/ScreenSharingView.svelte' @@ -29,12 +29,17 @@ export let canMaximize: boolean = true export let room: TypeRoom - let roomEl: HTMLDivElement + let roomElement: HTMLDivElement | undefined = undefined let withScreenSharing: boolean = false let loading: boolean = false let configured: boolean = false + defineSeparators('love-room', [ + { minSize: 14, size: 'auto', maxSize: 'auto' }, + { minSize: 14, size: 18, maxSize: 75 } + ]) + onMount(async () => { loading = true const wsURL = getMetadata(love.metadata.WebSocketURL) @@ -46,29 +51,21 @@ await waitForOfficeLoaded() - roomEl && roomEl.addEventListener('fullscreenchange', handleFullScreen) + roomElement?.addEventListener('fullscreenchange', handleFullScreen) loading = false }) - let gridStyle = '' - let columns: number = 0 - let rows: number = 0 - onDestroy(() => { - roomEl.removeEventListener('fullscreenchange', handleFullScreen) + roomElement?.removeEventListener('fullscreenchange', handleFullScreen) }) - function updateStyle (count: number, screenSharing: boolean): void { - columns = screenSharing ? 1 : Math.min(Math.ceil(Math.sqrt(count)), 8) - rows = Math.ceil(count / columns) - gridStyle = `grid-template-columns: repeat(${columns}, 1fr); aspect-ratio: ${columns * 1280}/${rows * 720};` + const handleFullScreen = (): void => { + $isFullScreen = document.fullscreenElement != null } - const handleFullScreen = () => ($isFullScreen = document.fullscreenElement != null) - function checkFullscreen (): void { const needFullScreen = $isFullScreen - if (document.fullscreenElement && !needFullScreen) { + if (document.fullscreenElement != null && !needFullScreen) { document .exitFullscreen() .then(() => { @@ -78,8 +75,8 @@ console.log(`Error exiting fullscreen mode: ${err.message} (${err.name})`) $isFullScreen = false }) - } else if (!document.fullscreenElement && needFullScreen && roomEl != null) { - roomEl + } else if (document.fullscreenElement == null && needFullScreen && roomElement != null) { + roomElement .requestFullscreen() .then(() => { $isFullScreen = true @@ -93,8 +90,8 @@ function onFullScreen (): void { const needFullScreen = !$isFullScreen - if (!document.fullscreenElement && needFullScreen && roomEl != null) { - roomEl + if (document.fullscreenElement == null && needFullScreen && roomElement != null) { + roomElement .requestFullscreen() .then(() => { $isFullScreen = true @@ -116,11 +113,12 @@ } } - $: if (((document.fullscreenElement && !$isFullScreen) || $isFullScreen) && roomEl) checkFullscreen() - $: updateStyle(lk.numParticipants, withScreenSharing) + $: if (((document.fullscreenElement != null && !$isFullScreen) || $isFullScreen) && roomElement !== undefined) { + checkFullscreen() + } -
+
{#if !configured}
@@ -132,20 +130,17 @@
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 @@
- -
- { - dispatchFit(evt.detail > 0) - }} - /> -
-
+
+ { + dispatchFit() + }} + /> +
@@ -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,