Skip to content

Commit c149bf8

Browse files
committed
refactor(layout): masonry layout
1 parent b3ed09b commit c149bf8

3 files changed

Lines changed: 136 additions & 55 deletions

File tree

frontend/components.d.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,6 @@ declare module "vue" {
5757
LucideHand: typeof import("~icons/lucide/hand")["default"];
5858
LucideHome: typeof import("~icons/lucide/home")["default"];
5959
LucideLoader: typeof import("~icons/lucide/loader")["default"];
60-
LucideMessageCircle: typeof import(
61-
"~icons/lucide/message-circle",
62-
)["default"];
6360
LucideMessageSquare: typeof import(
6461
"~icons/lucide/message-square",
6562
)["default"];

frontend/src/components/VideoGrid.vue

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
<TransitionGroup
44
name="tile"
55
tag="div"
6-
class="h-full grid gap-2 call-grid"
7-
:class="gridClass"
6+
class="h-full call-grid"
87
:style="gridStyle"
98
>
109
<!-- Local user video -->
@@ -17,21 +16,32 @@
1716
:isActiveSpeaker="activeSpeakerIds.includes(localParticipant.user_id)"
1817
:videoRef="setLocalVideoRef"
1918
:tileCount="visibleTileCount"
19+
:style="tileStyle"
2020
/>
2121

2222
<!-- Remote participants -->
23-
<ParticipantTile
23+
<template
2424
v-for="participant in allParticipants"
25-
:class="{ 'hidden-tile': !participant.isVisible }"
26-
:key="'tile-' + participant.user_id"
27-
:participant="participant"
28-
:isLocal="false"
29-
:isVideoEnabled="participant.video_enabled"
30-
:isAudioEnabled="participant.audio_enabled"
31-
:isActiveSpeaker="activeSpeakerIds.includes(participant.user_id)"
32-
:videoRef="getRemoteVideoRef(participant.user_id)"
33-
:tileCount="visibleTileCount"
34-
/>
25+
:key="'group-' + participant.user_id"
26+
>
27+
<ParticipantTile
28+
:class="{ 'hidden-tile': !participant.isVisible }"
29+
:participant="participant"
30+
:isLocal="false"
31+
:isVideoEnabled="participant.video_enabled"
32+
:isAudioEnabled="participant.audio_enabled"
33+
:isActiveSpeaker="activeSpeakerIds.includes(participant.user_id)"
34+
:videoRef="getRemoteVideoRef(participant.user_id)"
35+
:tileCount="visibleTileCount"
36+
:style="participant.isVisible ? tileStyle : undefined"
37+
/>
38+
<!-- needed for dynamic row breaks -->
39+
<div
40+
v-if="participant.isVisible && participant.needsBreakAfter"
41+
:key="'break-' + participant.user_id"
42+
class="flex-break"
43+
/>
44+
</template>
3545

3646
<!-- Grouping tile for overflow participants -->
3747
<GroupTile
@@ -41,6 +51,7 @@
4151
:tooltip="hiddenParticipantsTooltip"
4252
:participants="displayParticipants.hidden"
4353
size="medium"
54+
:style="tileStyle"
4455
@click="handleGroupTileClick"
4556
/>
4657
</TransitionGroup>
@@ -133,8 +144,8 @@ const localParticipant = computed(() => {
133144
const {
134145
displayParticipants,
135146
allParticipants,
136-
gridClass,
137147
gridStyle,
148+
tileStyle,
138149
visibleTileCount,
139150
hiddenParticipantsTooltip,
140151
} = useVideoGridLayout(participants, meetingState);
@@ -189,6 +200,16 @@ const hiddenParticipantReactions = computed(() => {
189200
z-index: 0;
190201
}
191202
203+
/* Invisible element that forces flexbox to start a new line. */
204+
.flex-break {
205+
flex-basis: 100%;
206+
height: 0;
207+
overflow: hidden;
208+
padding: 0;
209+
margin: 0;
210+
border: 0;
211+
}
212+
192213
/* Animation styles */
193214
.tile-enter-from,
194215
.tile-leave-to {

frontend/src/composables/useVideoGridLayout.ts

Lines changed: 101 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,32 @@ interface DisplayParticipantsResult {
1616
interface DisplayParticipant extends Participant {
1717
isVisible: boolean;
1818
slotIndex: number;
19+
tileStyle: Record<string, string>;
20+
needsBreakAfter: boolean;
1921
}
2022

2123
interface GridStyle {
22-
"grid-auto-rows": string;
23-
"grid-template-columns": string;
24+
display: string;
25+
"flex-wrap": string;
26+
"justify-content": string;
27+
"align-content": string;
28+
"column-gap": string;
29+
overflow: string;
30+
}
31+
32+
interface TileStyle {
33+
width: string;
34+
height: string;
35+
minWidth: string;
36+
minHeight: string;
37+
[key: string]: string;
2438
}
2539

2640
interface UseVideoGridLayoutReturn {
2741
displayParticipants: ComputedRef<DisplayParticipantsResult>;
2842
allParticipants: ComputedRef<DisplayParticipant[]>;
29-
gridClass: ComputedRef<string>;
3043
gridStyle: ComputedRef<GridStyle>;
44+
tileStyle: ComputedRef<TileStyle>;
3145
visibleTileCount: ComputedRef<number>;
3246
hiddenParticipantsTooltip: ComputedRef<string>;
3347
maxVisibleTiles: ComputedRef<number>;
@@ -41,6 +55,8 @@ interface UseVideoGridLayoutReturn {
4155
* - Maximum 4 rows at any screen size
4256
* - Columns adapt to screen size (2 mobile, 3 tablet, 4 desktop)
4357
* - Overflow participants go to grouped tile
58+
* - Tiles are distributed evenly across rows (e.g. 7 → [3,2,2])
59+
* - Shorter rows are centered via justify-content with flex breaks
4460
*/
4561
export function useVideoGridLayout(
4662
participants: Ref<Record<string, Participant>>,
@@ -61,6 +77,27 @@ export function useVideoGridLayout(
6177
return Math.min(4, maxCols); // 4x4 max
6278
};
6379

80+
/**
81+
* Distribute totalTiles evenly across rows.
82+
* E.g. 7 tiles, 3 max cols → [3, 2, 2] instead of [3, 3, 1]
83+
*/
84+
const getRowDistribution = (
85+
totalTiles: number,
86+
maxCols: number,
87+
): number[] => {
88+
if (totalTiles <= 0) return [];
89+
const cols = getOptimalColumns(totalTiles, maxCols);
90+
const rows = Math.ceil(totalTiles / cols);
91+
const base = Math.floor(totalTiles / rows);
92+
const extra = totalTiles % rows;
93+
94+
const distribution: number[] = [];
95+
for (let i = 0; i < rows; i++) {
96+
distribution.push(base + (i < extra ? 1 : 0));
97+
}
98+
return distribution;
99+
};
100+
64101
const maxVisibleTiles = computed<number>(() => {
65102
const cols = maxColumns.value;
66103
const maxRows = 4;
@@ -186,32 +223,14 @@ export function useVideoGridLayout(
186223
return { list: orderedVisible, hidden, extra: hidden.length };
187224
});
188225

189-
// Calculate grid columns based on total visible tiles and screen size
190-
const gridClass = computed<string>(() => {
191-
const totalVisibleTiles =
192-
1 + // local
193-
displayParticipants.value.list.length +
194-
(displayParticipants.value.extra > 0 ? 1 : 0); // grouped tile if present
195-
196-
const cols = getOptimalColumns(totalVisibleTiles, maxColumns.value);
197-
198-
return `grid-cols-${cols}`;
199-
});
200-
201-
// Calculate grid style for equal row heights
202-
const gridStyle = computed<GridStyle>(() => {
203-
const totalVisibleTiles =
204-
1 +
205-
displayParticipants.value.list.length +
206-
(displayParticipants.value.extra > 0 ? 1 : 0);
207-
208-
const cols = getOptimalColumns(totalVisibleTiles, maxColumns.value);
209-
210-
return {
211-
"grid-auto-rows": "1fr",
212-
"grid-template-columns": `repeat(${cols}, minmax(0, 1fr))`,
213-
};
214-
});
226+
const gridStyle = computed<GridStyle>(() => ({
227+
display: "flex",
228+
"flex-wrap": "wrap",
229+
"justify-content": "center",
230+
"align-content": "start",
231+
"column-gap": "0.5rem",
232+
overflow: "hidden",
233+
}));
215234

216235
// Total visible tile count for avatar sizing
217236
const visibleTileCount = computed<number>(() => {
@@ -222,6 +241,46 @@ export function useVideoGridLayout(
222241
);
223242
});
224243

244+
/**
245+
* Row break indices: visible tile indices after which a flex line-break
246+
* element should be inserted. This forces the desired row distribution.
247+
*
248+
* E.g. for distribution [3, 2, 2], breaks = {2, 4}
249+
* → break after vis index 2 (end of row 1)
250+
* → break after vis index 4 (end of row 2)
251+
*/
252+
const rowBreakIndices = computed<Set<number>>(() => {
253+
const total = visibleTileCount.value;
254+
const distribution = getRowDistribution(total, maxColumns.value);
255+
const breaks = new Set<number>();
256+
let cumulative = 0;
257+
for (let r = 0; r < distribution.length - 1; r++) {
258+
cumulative += distribution[r];
259+
breaks.add(cumulative - 1);
260+
}
261+
return breaks;
262+
});
263+
264+
// all tiles get the same dimensions based on
265+
// the first row's column count and total number of rows.
266+
const tileStyle = computed<TileStyle>(() => {
267+
const total = visibleTileCount.value;
268+
const distribution = getRowDistribution(total, maxColumns.value);
269+
const firstRowCols = distribution[0] || 1;
270+
const rows = distribution.length || 1;
271+
const gap = "0.5rem";
272+
273+
const verticalGaps = rows - 1;
274+
275+
return {
276+
width: `calc((100% - ${firstRowCols - 1} * ${gap}) / ${firstRowCols})`,
277+
height: `calc((100% - ${verticalGaps} * ${gap}) / ${rows})`,
278+
minWidth: "0",
279+
minHeight: "0",
280+
marginBottom: gap,
281+
};
282+
});
283+
225284
const hiddenParticipantsTooltip = computed<string>(() => {
226285
const hidden = displayParticipants.value.hidden || [];
227286
if (!hidden.length) return "";
@@ -241,28 +300,32 @@ export function useVideoGridLayout(
241300

242301
const allParticipants = computed<DisplayParticipant[]>(() => {
243302
const dp = displayParticipants.value;
244-
245-
// map of visible participants to their slot index
246-
const slotMap = new Map<string, number>();
247-
dp.list.forEach((p, idx) => slotMap.set(p.user_id, idx));
303+
const ts = tileStyle.value;
304+
const breaks = rowBreakIndices.value;
248305

249306
const allWithVisibility: DisplayParticipant[] = [];
250307

251-
// add visible ones first
252-
for (const p of dp.list) {
308+
// visible ones — vis index = i + 1 (local is vis 0)
309+
for (let i = 0; i < dp.list.length; i++) {
310+
const p = dp.list[i];
311+
const visIndex = i + 1;
253312
allWithVisibility.push({
254313
...p,
255314
isVisible: true,
256-
slotIndex: slotMap.get(p.user_id) ?? 999,
315+
slotIndex: i,
316+
tileStyle: ts,
317+
needsBreakAfter: breaks.has(visIndex),
257318
});
258319
}
259320

260-
// then hidden ones
321+
// hidden ones don't need tile styles or breaks
261322
for (const p of dp.hidden) {
262323
allWithVisibility.push({
263324
...p,
264325
isVisible: false,
265326
slotIndex: 999,
327+
tileStyle: {},
328+
needsBreakAfter: false,
266329
});
267330
}
268331

@@ -272,8 +335,8 @@ export function useVideoGridLayout(
272335
return {
273336
displayParticipants,
274337
allParticipants,
275-
gridClass,
276338
gridStyle,
339+
tileStyle,
277340
visibleTileCount,
278341
hiddenParticipantsTooltip,
279342
maxVisibleTiles,

0 commit comments

Comments
 (0)