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);
}