@@ -16,18 +16,32 @@ interface DisplayParticipantsResult {
1616interface DisplayParticipant extends Participant {
1717 isVisible : boolean ;
1818 slotIndex : number ;
19+ tileStyle : Record < string , string > ;
20+ needsBreakAfter : boolean ;
1921}
2022
2123interface 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
2640interface 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 */
4561export 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