diff --git a/src/components/CallView/CallView.vue b/src/components/CallView/CallView.vue index a72784c9020..3608ed5f9dc 100644 --- a/src/components/CallView/CallView.vue +++ b/src/components/CallView/CallView.vue @@ -34,7 +34,7 @@ :model="selectedCallParticipantModel" :sharedData="sharedDatas[selectedVideoPeerId]" :showTalkingHighlight="false" - :isOneToOne="isOneToOne" + :isFullPage="showFullPage" isGrid isBig fitVideo /> @@ -77,7 +77,6 @@ :models="promotedSpeakerModels" :sharedDatas="sharedDatas" :showVideoOverlay="showVideoOverlay" - :isOneToOne="isOneToOne" @selectVideo="handleSelectVideo" /> @@ -451,12 +450,13 @@ export default { return this.selectedVideoPeerId !== null && !this.screens.includes(this.selectedVideoPeerId) }, - isOneToOne() { - return this.callParticipantModels.length === 1 - }, - + // Whether the promoted video covers the whole call view, with the stripe + // floating over it rather than taking room of its own. A promoted area + // held by a single participant is laid out that way, and so is one with + // nobody in it yet. showFullPage() { - return this.isOneToOne && !(this.showLocalScreen || this.showRemoteScreen || this.showSelectedScreen) + return this.callParticipantModels.length <= 1 + && !(this.showLocalScreen || this.showRemoteScreen || this.showSelectedScreen) }, hasLocalVideo() { diff --git a/src/components/CallView/Grid/SpeakersGrid.vue b/src/components/CallView/Grid/SpeakersGrid.vue index d9d595c804e..63c8fbdd750 100644 --- a/src/components/CallView/Grid/SpeakersGrid.vue +++ b/src/components/CallView/Grid/SpeakersGrid.vue @@ -23,8 +23,6 @@ const props = defineProps<{ sharedDatas: Record /** Whether the video overlay is currently shown */ showVideoOverlay?: boolean - /** Whether the call is a one to one conversation */ - isOneToOne?: boolean }>() const emit = defineEmits<{ @@ -101,7 +99,6 @@ function tileStyle(index: number) { :model="model" :sharedData="sharedDatas[model.attributes.peerId]" :showVideoOverlay="showVideoOverlay" - :isOneToOne="isOneToOne" isGrid fitVideo @clickVideo="emit('selectVideo', model.attributes.peerId)" /> diff --git a/src/components/CallView/Grid/StripeControls.vue b/src/components/CallView/Grid/StripeControls.vue index bedb241903b..9ccabb76ebd 100644 --- a/src/components/CallView/Grid/StripeControls.vue +++ b/src/components/CallView/Grid/StripeControls.vue @@ -122,7 +122,7 @@ const toggleLabel = computed(() => props.isOpen align-items: center; gap: var(--default-grid-baseline); width: fit-content; - padding-block-start: var(--default-grid-baseline); + padding-block-start: var(--grid-gap); // Barely there while the call is not being looked at, as they sit over the // tiles, and in their own color as soon as it is opacity: .4; diff --git a/src/components/CallView/Grid/VideosGrid.vue b/src/components/CallView/Grid/VideosGrid.vue index 2046e4d993c..cd8717b15fe 100644 --- a/src/components/CallView/Grid/VideosGrid.vue +++ b/src/components/CallView/Grid/VideosGrid.vue @@ -173,6 +173,7 @@ import { getHalfColumnCount, getHalfColumnMaxWidth, getHalfColumnMinWidth, + getJustifyContent, GRID_GAP, TARGET_ASPECT_RATIO, TILE_COLUMN_SPAN, @@ -465,8 +466,14 @@ export default { gridTemplateColumns: `repeat(${getHalfColumnCount(columns)}, ${halfColumnWidth})`, gridTemplateRows: `repeat(${rows}, minmax(${this.dpiAwareMinHeight}px, 1fr))`, // The columns no longer take the whole width once they are - // capped, so the grid itself has to center them - justifyContent: 'center', + // capped, so the grid itself has to place them + justifyContent: getJustifyContent({ + isStripe: this.isStripe, + totalTiles: this.totalTiles, + // The local video is laid out after the remote tiles, so it + // is the only tile of the grid when no remote one is shown + isLocalVideoAlone: !this.noLocalVideoReserve && this.displayedVideos.length === 0, + }), } }, @@ -614,6 +621,7 @@ export default { &--stripe { height: var(--stripe-height); + margin-block-start: var(--grid-gap); } } @@ -669,6 +677,22 @@ export default { // Kept out of the grid itself, whose measured height is the height of its // tiles padding-block: var(--grid-gap); + // Keeps the tiles off the rounded corners of the card + padding-inline: var(--grid-gap); + + // The card the tiles sit on + .grid-main-wrapper:not(.overlap) & { + background-color: #262626; + border-radius: var(--border-radius-container); + } + + // A stripe overlapping the promoted video takes no card of its own, which + // would band that video across: its tiles - the self camera, the only one + // left in a one to one call - are lifted off the call by a shadow instead + .overlap & :deep(.localVideoContainer), + .overlap & :deep(.video-container-grid) { + box-shadow: 0 0px 10px rgba(0, 0, 0, 0.5); + } } .dev-mode-video { @@ -744,15 +768,15 @@ export default { .stripe-controls-position { position: absolute; - top: var(--grid-gap); - inset-inline-end: var(--grid-gap); + top: calc(var(--default-grid-baseline) * 3); + inset-inline-end: calc(var(--default-grid-baseline) * 3); z-index: 2; transition: top var(--animation-slow) ease-in-out; // A collapsed stripe holds no tile to sit over, and no room of its own to // sit in, so the controls take the room above it &--collapsed { - top: calc(-1 * (var(--clickable-area-small) + var(--default-grid-baseline) + var(--grid-gap))); + top: calc(-1 * (var(--clickable-area-small) + var(--grid-gap) + var(--grid-gap))); } } diff --git a/src/components/CallView/Grid/gridLayout.spec.ts b/src/components/CallView/Grid/gridLayout.spec.ts index 34e3d943198..5e059ca351c 100644 --- a/src/components/CallView/Grid/gridLayout.spec.ts +++ b/src/components/CallView/Grid/gridLayout.spec.ts @@ -11,6 +11,7 @@ import { getHalfColumnCount, getHalfColumnMaxWidth, getHalfColumnMinWidth, + getJustifyContent, getMinTileHeight, getMinTileWidth, GRID_GAP, @@ -342,4 +343,25 @@ describe('gridLayout', () => { } }) }) + + describe('getJustifyContent', () => { + test('centers the tiles of the grid', () => { + expect(getJustifyContent({ isStripe: false, totalTiles: 1, isLocalVideoAlone: true })).toBe('center') + expect(getJustifyContent({ isStripe: false, totalTiles: 5, isLocalVideoAlone: false })).toBe('center') + }) + + test('centers the tiles of a stripe holding several of them', () => { + expect(getJustifyContent({ isStripe: true, totalTiles: 2, isLocalVideoAlone: false })).toBe('center') + }) + + test('keeps a lone local video at the end of the stripe', () => { + expect(getJustifyContent({ isStripe: true, totalTiles: 1, isLocalVideoAlone: true })).toBe('end') + }) + + test('keeps a lone remote tile at the start of the stripe', () => { + // The local video is not shown as a tile, so the only tile left is a + // remote one and it belongs to the start of the row + expect(getJustifyContent({ isStripe: true, totalTiles: 1, isLocalVideoAlone: false })).toBe('start') + }) + }) }) diff --git a/src/components/CallView/Grid/gridLayout.ts b/src/components/CallView/Grid/gridLayout.ts index 7070d901026..5ebe4f05a60 100644 --- a/src/components/CallView/Grid/gridLayout.ts +++ b/src/components/CallView/Grid/gridLayout.ts @@ -375,3 +375,43 @@ export function computeTilePlacements({ return placements } + +/** Alignment of the tile columns within the grid */ +export type GridJustifyContent = 'center' | 'start' | 'end' + +type JustifyContentOptions = { + /** Whether the grid is shown as a stripe */ + isStripe: boolean + /** Number of tiles laid out on the page, including the local video tile */ + totalTiles: number + /** Whether the local video is the only tile of the grid */ + isLocalVideoAlone: boolean +} + +/** + * Alignment of the tile columns within the grid. + * + * The columns no longer take the whole width once they are capped, so the grid + * itself has to place them. They are centered, except in a stripe holding a + * single tile: that tile keeps the side it would sit on in a fuller stripe + * rather than moving to the middle. The local video is laid out after the + * remote ones, so it stays at the inline end, while a lone remote tile keeps + * the inline start the tiles are laid out from - which is where it belongs once + * the local video is not shown as a tile. + * + * @param options - the layout inputs + * @param options.isStripe - whether the grid is shown as a stripe + * @param options.totalTiles - number of tiles on the page, including the local video tile + * @param options.isLocalVideoAlone - whether the local video is the only tile + */ +export function getJustifyContent({ + isStripe, + totalTiles, + isLocalVideoAlone, +}: JustifyContentOptions): GridJustifyContent { + if (!isStripe || totalTiles !== 1) { + return 'center' + } + + return isLocalVideoAlone ? 'end' : 'start' +} diff --git a/src/components/CallView/Grid/useGridDimensions.ts b/src/components/CallView/Grid/useGridDimensions.ts index 953a92d2ec9..8af81439331 100644 --- a/src/components/CallView/Grid/useGridDimensions.ts +++ b/src/components/CallView/Grid/useGridDimensions.ts @@ -90,8 +90,21 @@ export function useGridDimensions({ return } - gridWidth.value = element.clientWidth - gridHeight.value = element.clientHeight + const width = element.clientWidth + const height = element.clientHeight + + // The grid is hidden or mid-transition (the stripe collapses and expands + // by its height): measuring it now would shrink the layout down to a + // single row and it would be kept until the next resize, as the element + // is given back its size by a transition rather than by a layout change + // of its own. Keep the current layout instead and wait for the + // `ResizeObserver` to report the final size. + if (width <= 0 || height <= 0) { + return + } + + gridWidth.value = width + gridHeight.value = height const dimensions = computeGridDimensions({ gridWidth: gridWidth.value, @@ -115,10 +128,26 @@ export function useGridDimensions({ let resizeObserver: ResizeObserver | null = null onMounted(() => { + resizeObserver = new ResizeObserver(debouncedRecompute) + if (wrapper.value) { - resizeObserver = new ResizeObserver(debouncedRecompute) resizeObserver.observe(wrapper.value) } + + // The grid element is observed as well: it is the one being measured and + // its size does not always follow the size of the wrapper, which keeps + // the whole height of the call view while the grid is collapsed and + // expanded by a transition. The element is unmounted along with the + // collapsed stripe, so it is observed again whenever it comes back. + watch(grid, (element, previousElement) => { + if (previousElement) { + resizeObserver?.unobserve(previousElement) + } + if (element) { + resizeObserver?.observe(element) + } + }, { immediate: true }) + recompute() }) diff --git a/src/components/CallView/shared/VideoBackground.vue b/src/components/CallView/shared/VideoBackground.vue index a3105f63158..0e3cee8c32e 100644 --- a/src/components/CallView/shared/VideoBackground.vue +++ b/src/components/CallView/shared/VideoBackground.vue @@ -21,12 +21,14 @@ const isDarkTheme = useIsDarkTheme() top: 0; height: 100%; width: 100%; - background-color: rgba(var(--overlay-color), 0.3); - background-image: none; + // A solid surface: nothing of the call view behind the tile shows through + // it. The two colours are what the translucent tint they replace used to + // composite to over the background of the call, so a tile keeps the shade + // it had in either theme. + background-color: #363636; - --overlay-color: 0, 0, 0; &.dark-theme { - --overlay-color: 255, 255, 255; + background-color: #515151; } } diff --git a/src/components/CallView/shared/VideoVue.vue b/src/components/CallView/shared/VideoVue.vue index 75d18ca2006..0ae1711138b 100644 --- a/src/components/CallView/shared/VideoVue.vue +++ b/src/components/CallView/shared/VideoVue.vue @@ -189,8 +189,9 @@ export default { default: false, }, - // True when this video component is used in one to one conversations - isOneToOne: { + // True when this video fills the whole call view, with the stripe + // floating over it + isFullPage: { type: Boolean, default: false, }, @@ -323,7 +324,7 @@ export default { presenter: this.isPresenterOverlay && this.mouseover, 'video-container-grid': this.isGrid, 'video-container-big': this.isBig, - 'one-to-one': this.isOneToOne, + 'full-page': this.isFullPage, 'presenter-overlay': this.isPresenterOverlay, } }, @@ -654,7 +655,7 @@ export default { .video-container-big { position: absolute; - &.one-to-one { + &.full-page { width: calc(100% - var(--wrapper-padding) * 2); }