@agatx/serenada-core 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/ConsoleLogger.d.ts +6 -0
  2. package/dist/ConsoleLogger.d.ts.map +1 -0
  3. package/dist/ConsoleLogger.js +21 -0
  4. package/dist/ConsoleLogger.js.map +1 -0
  5. package/dist/RoomWatcher.d.ts +34 -0
  6. package/dist/RoomWatcher.d.ts.map +1 -0
  7. package/dist/RoomWatcher.js +103 -0
  8. package/dist/RoomWatcher.js.map +1 -0
  9. package/dist/SerenadaCore.d.ts +47 -0
  10. package/dist/SerenadaCore.d.ts.map +1 -0
  11. package/dist/SerenadaCore.js +141 -0
  12. package/dist/SerenadaCore.js.map +1 -0
  13. package/dist/SerenadaDiagnostics.d.ts +49 -0
  14. package/dist/SerenadaDiagnostics.d.ts.map +1 -0
  15. package/dist/SerenadaDiagnostics.js +421 -0
  16. package/dist/SerenadaDiagnostics.js.map +1 -0
  17. package/dist/SerenadaServerProvider.d.ts +48 -0
  18. package/dist/SerenadaServerProvider.d.ts.map +1 -0
  19. package/dist/SerenadaServerProvider.js +296 -0
  20. package/dist/SerenadaServerProvider.js.map +1 -0
  21. package/dist/SerenadaSession.d.ts +180 -0
  22. package/dist/SerenadaSession.d.ts.map +1 -0
  23. package/dist/SerenadaSession.js +1082 -0
  24. package/dist/SerenadaSession.js.map +1 -0
  25. package/dist/SignalingProvider.d.ts +132 -0
  26. package/dist/SignalingProvider.d.ts.map +1 -0
  27. package/dist/SignalingProvider.js +50 -0
  28. package/dist/SignalingProvider.js.map +1 -0
  29. package/dist/api/roomApi.d.ts +2 -0
  30. package/dist/api/roomApi.d.ts.map +1 -0
  31. package/dist/api/roomApi.js +14 -0
  32. package/dist/api/roomApi.js.map +1 -0
  33. package/dist/cameraModes.d.ts +13 -0
  34. package/dist/cameraModes.d.ts.map +1 -0
  35. package/dist/cameraModes.js +35 -0
  36. package/dist/cameraModes.js.map +1 -0
  37. package/dist/configValidation.d.ts +10 -0
  38. package/dist/configValidation.d.ts.map +1 -0
  39. package/dist/configValidation.js +24 -0
  40. package/dist/configValidation.js.map +1 -0
  41. package/dist/constants.d.ts +33 -0
  42. package/dist/constants.d.ts.map +1 -0
  43. package/dist/constants.js +65 -0
  44. package/dist/constants.js.map +1 -0
  45. package/dist/formatError.d.ts +3 -0
  46. package/dist/formatError.d.ts.map +1 -0
  47. package/dist/formatError.js +7 -0
  48. package/dist/formatError.js.map +1 -0
  49. package/dist/iceServers.d.ts +2 -0
  50. package/dist/iceServers.d.ts.map +1 -0
  51. package/dist/iceServers.js +21 -0
  52. package/dist/iceServers.js.map +1 -0
  53. package/dist/index.d.ts +55 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +44 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/layout/computeLayout.d.ts +81 -0
  58. package/dist/layout/computeLayout.d.ts.map +1 -0
  59. package/dist/layout/computeLayout.js +380 -0
  60. package/dist/layout/computeLayout.js.map +1 -0
  61. package/dist/media/AudioLevelMonitor.d.ts +51 -0
  62. package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
  63. package/dist/media/AudioLevelMonitor.js +179 -0
  64. package/dist/media/AudioLevelMonitor.js.map +1 -0
  65. package/dist/media/MediaEngine.d.ts +137 -0
  66. package/dist/media/MediaEngine.d.ts.map +1 -0
  67. package/dist/media/MediaEngine.js +1224 -0
  68. package/dist/media/MediaEngine.js.map +1 -0
  69. package/dist/media/callStats.d.ts +16 -0
  70. package/dist/media/callStats.d.ts.map +1 -0
  71. package/dist/media/callStats.js +214 -0
  72. package/dist/media/callStats.js.map +1 -0
  73. package/dist/media/localVideoRecovery.d.ts +16 -0
  74. package/dist/media/localVideoRecovery.d.ts.map +1 -0
  75. package/dist/media/localVideoRecovery.js +14 -0
  76. package/dist/media/localVideoRecovery.js.map +1 -0
  77. package/dist/recoveryStorage.d.ts +33 -0
  78. package/dist/recoveryStorage.d.ts.map +1 -0
  79. package/dist/recoveryStorage.js +88 -0
  80. package/dist/recoveryStorage.js.map +1 -0
  81. package/dist/serverUrls.d.ts +8 -0
  82. package/dist/serverUrls.d.ts.map +1 -0
  83. package/dist/serverUrls.js +65 -0
  84. package/dist/serverUrls.js.map +1 -0
  85. package/dist/signaling/SignalingEngine.d.ts +126 -0
  86. package/dist/signaling/SignalingEngine.d.ts.map +1 -0
  87. package/dist/signaling/SignalingEngine.js +720 -0
  88. package/dist/signaling/SignalingEngine.js.map +1 -0
  89. package/dist/signaling/payloads.d.ts +76 -0
  90. package/dist/signaling/payloads.d.ts.map +1 -0
  91. package/dist/signaling/payloads.js +160 -0
  92. package/dist/signaling/payloads.js.map +1 -0
  93. package/dist/signaling/roomStatuses.d.ts +9 -0
  94. package/dist/signaling/roomStatuses.d.ts.map +1 -0
  95. package/dist/signaling/roomStatuses.js +71 -0
  96. package/dist/signaling/roomStatuses.js.map +1 -0
  97. package/dist/signaling/transportConfig.d.ts +3 -0
  98. package/dist/signaling/transportConfig.d.ts.map +1 -0
  99. package/dist/signaling/transportConfig.js +27 -0
  100. package/dist/signaling/transportConfig.js.map +1 -0
  101. package/dist/signaling/transports/index.d.ts +13 -0
  102. package/dist/signaling/transports/index.d.ts.map +1 -0
  103. package/dist/signaling/transports/index.js +11 -0
  104. package/dist/signaling/transports/index.js.map +1 -0
  105. package/dist/signaling/transports/sse.d.ts +26 -0
  106. package/dist/signaling/transports/sse.d.ts.map +1 -0
  107. package/dist/signaling/transports/sse.js +131 -0
  108. package/dist/signaling/transports/sse.js.map +1 -0
  109. package/dist/signaling/transports/types.d.ts +17 -0
  110. package/dist/signaling/transports/types.d.ts.map +1 -0
  111. package/dist/signaling/transports/types.js +2 -0
  112. package/dist/signaling/transports/types.js.map +1 -0
  113. package/dist/signaling/transports/ws.d.ts +21 -0
  114. package/dist/signaling/transports/ws.d.ts.map +1 -0
  115. package/dist/signaling/transports/ws.js +93 -0
  116. package/dist/signaling/transports/ws.js.map +1 -0
  117. package/dist/signaling/types.d.ts +53 -0
  118. package/dist/signaling/types.d.ts.map +1 -0
  119. package/dist/signaling/types.js +2 -0
  120. package/dist/signaling/types.js.map +1 -0
  121. package/dist/types.d.ts +279 -0
  122. package/dist/types.d.ts.map +1 -0
  123. package/dist/types.js +3 -0
  124. package/dist/types.js.map +1 -0
  125. package/package.json +43 -0
  126. package/src/ConsoleLogger.ts +14 -0
  127. package/src/RoomWatcher.ts +127 -0
  128. package/src/SerenadaCore.ts +163 -0
  129. package/src/SerenadaDiagnostics.ts +485 -0
  130. package/src/SerenadaServerProvider.ts +362 -0
  131. package/src/SerenadaSession.ts +1258 -0
  132. package/src/SignalingProvider.ts +207 -0
  133. package/src/api/roomApi.ts +16 -0
  134. package/src/cameraModes.ts +34 -0
  135. package/src/configValidation.ts +35 -0
  136. package/src/constants.ts +77 -0
  137. package/src/formatError.ts +5 -0
  138. package/src/iceServers.ts +20 -0
  139. package/src/index.ts +155 -0
  140. package/src/layout/computeLayout.ts +639 -0
  141. package/src/media/AudioLevelMonitor.ts +190 -0
  142. package/src/media/MediaEngine.ts +1183 -0
  143. package/src/media/callStats.ts +260 -0
  144. package/src/media/localVideoRecovery.ts +39 -0
  145. package/src/recoveryStorage.ts +101 -0
  146. package/src/serverUrls.ts +69 -0
  147. package/src/signaling/SignalingEngine.ts +762 -0
  148. package/src/signaling/payloads.ts +215 -0
  149. package/src/signaling/roomStatuses.ts +89 -0
  150. package/src/signaling/transportConfig.ts +30 -0
  151. package/src/signaling/transports/index.ts +26 -0
  152. package/src/signaling/transports/sse.ts +146 -0
  153. package/src/signaling/transports/types.ts +19 -0
  154. package/src/signaling/transports/ws.ts +108 -0
  155. package/src/signaling/types.ts +68 -0
  156. package/src/types.ts +299 -0
@@ -0,0 +1,639 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Layout module – extracted from CallRoom.tsx
3
+ //
4
+ // Exports the original harmonic-mean grid algorithm (computeStageLayout) plus
5
+ // a new computeLayout() wrapper that accepts a CallScene and returns a
6
+ // LayoutResult with absolute tile positions.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ // ===== Legacy constants (exported for backward compat during migration) =====
10
+
11
+ export const MIN_STAGE_TILE_ASPECT = 9 / 16;
12
+ export const MAX_STAGE_TILE_ASPECT = 16 / 9;
13
+ export const DEFAULT_STAGE_TILE_ASPECT = 16 / 9;
14
+ export const STAGE_TILE_GAP_PX = 12;
15
+
16
+ // ===== Legacy types (exported for backward compat during migration) =========
17
+
18
+ export type StageTileSpec = {
19
+ cid: string;
20
+ aspectRatio: number;
21
+ };
22
+
23
+ export type StageTileLayout = {
24
+ cid: string;
25
+ width: number;
26
+ height: number;
27
+ };
28
+
29
+ export type StageRowLayout = {
30
+ items: StageTileLayout[];
31
+ };
32
+
33
+ // ===== Legacy functions (exact copies from CallRoom.tsx) ====================
34
+
35
+ export function clampStageTileAspectRatio(ratio?: number | null): number {
36
+ if (!ratio || !Number.isFinite(ratio) || ratio <= 0) {
37
+ return DEFAULT_STAGE_TILE_ASPECT;
38
+ }
39
+ return Math.min(MAX_STAGE_TILE_ASPECT, Math.max(MIN_STAGE_TILE_ASPECT, ratio));
40
+ }
41
+
42
+ export function computeStageLayout(tiles: StageTileSpec[], availableWidth: number, availableHeight: number, gap: number): StageRowLayout[] {
43
+ if (tiles.length === 0 || availableWidth <= 0 || availableHeight <= 0) {
44
+ return [];
45
+ }
46
+
47
+ const candidateRows: number[][][] = tiles.length === 1
48
+ ? [[[0]]]
49
+ : tiles.length === 2
50
+ ? [[[0, 1]], [[0], [1]]]
51
+ : [[[0, 1, 2]], [[0, 1], [2]], [[0], [1, 2]], [[0], [1], [2]]];
52
+
53
+ let bestLayout: StageRowLayout[] = [];
54
+ let bestHarmonicShortEdge = -1;
55
+ let bestMinShortEdge = -1;
56
+ let bestArea = -1;
57
+ let bestRowCount = Number.POSITIVE_INFINITY;
58
+
59
+ for (const rows of candidateRows) {
60
+ const baseHeights = rows.map((row) => {
61
+ const totalAspect = row.reduce((sum, index) => sum + tiles[index].aspectRatio, 0);
62
+ const rowWidth = availableWidth - gap * Math.max(0, row.length - 1);
63
+ return rowWidth > 0 && totalAspect > 0 ? rowWidth / totalAspect : 0;
64
+ });
65
+
66
+ const verticalGap = gap * Math.max(0, rows.length - 1);
67
+ const totalBaseHeight = baseHeights.reduce((sum, value) => sum + value, 0);
68
+ if (totalBaseHeight <= 0 || availableHeight <= verticalGap) {
69
+ continue;
70
+ }
71
+
72
+ const scale = Math.min(1, (availableHeight - verticalGap) / totalBaseHeight);
73
+ if (scale <= 0) {
74
+ continue;
75
+ }
76
+
77
+ const layout = rows.map((row, rowIndex) => {
78
+ const rowHeight = Math.max(1, Math.floor(baseHeights[rowIndex] * scale));
79
+ const items = row.map((index) => {
80
+ const tile = tiles[index];
81
+ return {
82
+ cid: tile.cid,
83
+ width: Math.max(1, Math.floor(tile.aspectRatio * rowHeight)),
84
+ height: rowHeight
85
+ };
86
+ });
87
+ return { items };
88
+ });
89
+
90
+ const area = layout.reduce((sum, row) => (
91
+ sum + row.items.reduce((rowArea, tile) => rowArea + tile.width * tile.height, 0)
92
+ ), 0);
93
+ const shortEdges = layout.flatMap((row) => row.items.map((tile) => Math.min(tile.width, tile.height)));
94
+ const minShortEdge = shortEdges.reduce((currentMin, shortEdge) => Math.min(currentMin, shortEdge), Number.POSITIVE_INFINITY);
95
+ const harmonicShortEdge = shortEdges.length / shortEdges.reduce((sum, shortEdge) => sum + (1 / shortEdge), 0);
96
+ const rowCount = layout.length;
97
+
98
+ const shortEdgeGainIsMeaningful = harmonicShortEdge > bestHarmonicShortEdge + 6;
99
+ const shortEdgeIsComparable = Math.abs(harmonicShortEdge - bestHarmonicShortEdge) <= 6;
100
+ const minShortEdgeImproved = minShortEdge > bestMinShortEdge + 1;
101
+ const minShortEdgeComparable = Math.abs(minShortEdge - bestMinShortEdge) <= 1;
102
+
103
+ if (
104
+ shortEdgeGainIsMeaningful ||
105
+ (
106
+ shortEdgeIsComparable && (
107
+ rowCount < bestRowCount ||
108
+ (rowCount === bestRowCount && (
109
+ minShortEdgeImproved ||
110
+ (minShortEdgeComparable && area > bestArea)
111
+ ))
112
+ )
113
+ ) ||
114
+ (
115
+ !shortEdgeIsComparable &&
116
+ harmonicShortEdge > bestHarmonicShortEdge &&
117
+ minShortEdgeImproved
118
+ )
119
+ ) {
120
+ bestHarmonicShortEdge = harmonicShortEdge;
121
+ bestMinShortEdge = minShortEdge;
122
+ bestArea = area;
123
+ bestRowCount = rowCount;
124
+ bestLayout = layout;
125
+ }
126
+ }
127
+
128
+ return bestLayout;
129
+ }
130
+
131
+ // ===== New input types ======================================================
132
+
133
+ export interface CallScene {
134
+ viewportWidth: number;
135
+ viewportHeight: number;
136
+ safeAreaInsets: Insets;
137
+ participants: SceneParticipant[];
138
+ localParticipantId: string;
139
+ activeSpeakerId: string | null;
140
+ pinnedParticipantId: string | null;
141
+ contentSource: ContentSource | null;
142
+ userPrefs: UserLayoutPrefs;
143
+ }
144
+
145
+ export interface SceneParticipant {
146
+ id: string;
147
+ role: 'local' | 'remote';
148
+ videoEnabled: boolean;
149
+ videoAspectRatio: number | null;
150
+ }
151
+
152
+ export interface ContentSource {
153
+ type: 'screenShare' | 'worldCamera' | 'compositeCamera';
154
+ ownerParticipantId: string;
155
+ aspectRatio: number | null;
156
+ }
157
+
158
+ export interface UserLayoutPrefs {
159
+ swappedLocalAndRemote: boolean;
160
+ dominantFit: 'cover' | 'contain';
161
+ }
162
+
163
+ export interface Insets {
164
+ top: number;
165
+ bottom: number;
166
+ left: number;
167
+ right: number;
168
+ }
169
+
170
+ // ===== New output types =====================================================
171
+
172
+ export type LayoutMode = 'solo' | 'pair' | 'grid' | 'focus' | 'content';
173
+ export type FitMode = 'cover' | 'contain';
174
+
175
+ export interface LayoutResult {
176
+ mode: LayoutMode;
177
+ tiles: TileLayout[];
178
+ localPip: PipLayout | null;
179
+ }
180
+
181
+ export interface TileLayout {
182
+ id: string;
183
+ type: 'participant' | 'contentSource';
184
+ frame: Rect;
185
+ fit: FitMode;
186
+ cornerRadius: number;
187
+ zOrder: number;
188
+ }
189
+
190
+ export interface PipLayout {
191
+ participantId: string;
192
+ frame: Rect;
193
+ fit: FitMode;
194
+ cornerRadius: number;
195
+ anchor: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
196
+ zOrder: number;
197
+ }
198
+
199
+ export interface Rect {
200
+ x: number;
201
+ y: number;
202
+ width: number;
203
+ height: number;
204
+ }
205
+
206
+ // ===== Device-group-based layout constants ==================================
207
+
208
+ const LAYOUT_CONSTANTS = {
209
+ gap: { phone: 8, tablet: 12, desktop: 12 },
210
+ outerPadding: { phone: 8, tablet: 16, desktop: 16 },
211
+ cornerRadius: { phone: 12, tablet: 14, desktop: 16 },
212
+ minTileAspect: 9 / 16,
213
+ maxTileAspect: 16 / 9,
214
+ defaultTileAspect: 16 / 9,
215
+ pipWidthFraction: { phone: 0.24, tablet: 0.20, desktop: 0.16 },
216
+ pipAspectRatio: 3 / 4,
217
+ pipInset: { phone: 12, tablet: 16, desktop: 20 },
218
+ pipCornerRadius: 12,
219
+ primaryRatio: 0.75,
220
+ thumbnailMinSize: { phone: 72, tablet: 96, desktop: 120 },
221
+ harmonicShortEdgeTolerance: 6,
222
+ minShortEdgeTolerance: 1,
223
+ };
224
+
225
+ // ===== Device group classification ==========================================
226
+
227
+ type DeviceGroup = 'phone' | 'tablet' | 'desktop';
228
+
229
+ function deviceGroup(viewportWidth: number, viewportHeight: number): DeviceGroup {
230
+ const shortSide = Math.min(viewportWidth, viewportHeight);
231
+ if (shortSide < 600) return 'phone';
232
+ if (shortSide < 1024) return 'tablet';
233
+ return 'desktop';
234
+ }
235
+
236
+ // ===== Mode derivation ======================================================
237
+
238
+ function deriveMode(scene: CallScene): LayoutMode {
239
+ if (scene.participants.length === 1) return 'solo';
240
+ if (scene.contentSource !== null) return 'content';
241
+ if (scene.pinnedParticipantId !== null) return 'focus';
242
+ if (scene.participants.length === 2) return 'pair';
243
+ return 'grid';
244
+ }
245
+
246
+ // ===== Grid tile absolute positioning =======================================
247
+
248
+ function gridTilesToAbsolute(
249
+ rows: StageRowLayout[],
250
+ availableX: number,
251
+ availableY: number,
252
+ availableWidth: number,
253
+ availableHeight: number,
254
+ gap: number,
255
+ ): { cid: string; frame: Rect }[] {
256
+ const totalRowsHeight =
257
+ rows.reduce((sum, row) => sum + row.items[0].height, 0) +
258
+ gap * Math.max(0, rows.length - 1);
259
+ let rowY = availableY + (availableHeight - totalRowsHeight) / 2;
260
+
261
+ const result: { cid: string; frame: Rect }[] = [];
262
+ for (const row of rows) {
263
+ const rowWidth =
264
+ row.items.reduce((sum, tile) => sum + tile.width, 0) +
265
+ gap * Math.max(0, row.items.length - 1);
266
+ let tileX = availableX + (availableWidth - rowWidth) / 2;
267
+ const rowHeight = row.items[0].height;
268
+
269
+ for (const tile of row.items) {
270
+ result.push({
271
+ cid: tile.cid,
272
+ frame: { x: tileX, y: rowY, width: tile.width, height: rowHeight },
273
+ });
274
+ tileX += tile.width + gap;
275
+ }
276
+ rowY += rowHeight + gap;
277
+ }
278
+ return result;
279
+ }
280
+
281
+ // ===== PIP computation ======================================================
282
+
283
+ function computePip(
284
+ participantId: string,
285
+ viewportWidth: number,
286
+ viewportHeight: number,
287
+ group: DeviceGroup,
288
+ videoAspectRatio: number | null,
289
+ ): PipLayout {
290
+ const widthFraction = LAYOUT_CONSTANTS.pipWidthFraction[group];
291
+ const inset = LAYOUT_CONSTANTS.pipInset[group];
292
+ const width = viewportWidth * widthFraction;
293
+ const ar = clampStageTileAspectRatio(videoAspectRatio ?? LAYOUT_CONSTANTS.pipAspectRatio);
294
+ const height = width / ar;
295
+
296
+ return {
297
+ participantId,
298
+ frame: {
299
+ x: viewportWidth - inset - width,
300
+ y: viewportHeight - inset - height,
301
+ width,
302
+ height,
303
+ },
304
+ fit: 'cover',
305
+ cornerRadius: LAYOUT_CONSTANTS.pipCornerRadius,
306
+ anchor: 'bottomRight',
307
+ zOrder: 1,
308
+ };
309
+ }
310
+
311
+ // ===== Focus / Content: primary + filmstrip layout ==========================
312
+
313
+ function computePrimaryWithFilmstrip(
314
+ primaryId: string,
315
+ primaryType: 'participant' | 'contentSource',
316
+ primaryFit: FitMode,
317
+ filmstripParticipants: SceneParticipant[],
318
+ areaX: number,
319
+ areaY: number,
320
+ areaWidth: number,
321
+ areaHeight: number,
322
+ gap: number,
323
+ cornerRadius: number,
324
+ group: DeviceGroup,
325
+ ): TileLayout[] {
326
+ const isLandscape = areaWidth > areaHeight;
327
+ const thumbnailMin = LAYOUT_CONSTANTS.thumbnailMinSize[group];
328
+ const filmstripCount = filmstripParticipants.length;
329
+
330
+ // Compute split ratio, ensuring filmstrip tiles meet minimum size
331
+ let primaryRatio = LAYOUT_CONSTANTS.primaryRatio;
332
+ if (filmstripCount > 0) {
333
+ if (isLandscape) {
334
+ // Secondary is a vertical strip on the right
335
+ const secondaryWidth = areaWidth * (1 - primaryRatio);
336
+ const tileHeight = (areaHeight - gap * Math.max(0, filmstripCount - 1)) / filmstripCount;
337
+ if (tileHeight < thumbnailMin || secondaryWidth < thumbnailMin) {
338
+ // Increase secondary proportion
339
+ const neededHeight = thumbnailMin * filmstripCount + gap * Math.max(0, filmstripCount - 1);
340
+ const neededWidth = thumbnailMin;
341
+ const ratioFromHeight = neededHeight > areaHeight ? 0.5 : primaryRatio;
342
+ const ratioFromWidth = 1 - neededWidth / areaWidth;
343
+ primaryRatio = Math.max(0.5, Math.min(primaryRatio, Math.min(ratioFromHeight, ratioFromWidth)));
344
+ }
345
+ } else {
346
+ // Secondary is a horizontal strip at the bottom
347
+ const secondaryHeight = areaHeight * (1 - primaryRatio);
348
+ const tileWidth = (areaWidth - gap * Math.max(0, filmstripCount - 1)) / filmstripCount;
349
+ if (tileWidth < thumbnailMin || secondaryHeight < thumbnailMin) {
350
+ const neededWidth = thumbnailMin * filmstripCount + gap * Math.max(0, filmstripCount - 1);
351
+ const neededHeight = thumbnailMin;
352
+ const ratioFromWidth = neededWidth > areaWidth ? 0.5 : primaryRatio;
353
+ const ratioFromHeight = 1 - neededHeight / areaHeight;
354
+ primaryRatio = Math.max(0.5, Math.min(primaryRatio, Math.min(ratioFromWidth, ratioFromHeight)));
355
+ }
356
+ }
357
+ }
358
+
359
+ const tiles: TileLayout[] = [];
360
+
361
+ if (isLandscape) {
362
+ // Primary on left, filmstrip on right
363
+ const primaryWidth = areaWidth * primaryRatio - gap / 2;
364
+ const secondaryWidth = areaWidth - primaryWidth - gap;
365
+
366
+ tiles.push({
367
+ id: primaryId,
368
+ type: primaryType,
369
+ frame: { x: areaX, y: areaY, width: primaryWidth, height: areaHeight },
370
+ fit: primaryFit,
371
+ cornerRadius,
372
+ zOrder: 0,
373
+ });
374
+
375
+ if (filmstripCount > 0) {
376
+ const stripX = areaX + primaryWidth + gap;
377
+ const tileHeight = (areaHeight - gap * Math.max(0, filmstripCount - 1)) / filmstripCount;
378
+ filmstripParticipants.forEach((p, i) => {
379
+ tiles.push({
380
+ id: p.id,
381
+ type: 'participant',
382
+ frame: {
383
+ x: stripX,
384
+ y: areaY + i * (tileHeight + gap),
385
+ width: secondaryWidth,
386
+ height: tileHeight,
387
+ },
388
+ fit: 'cover',
389
+ cornerRadius,
390
+ zOrder: i + 1,
391
+ });
392
+ });
393
+ }
394
+ } else {
395
+ // Primary on top, filmstrip on bottom
396
+ const primaryHeight = areaHeight * primaryRatio - gap / 2;
397
+ const secondaryHeight = areaHeight - primaryHeight - gap;
398
+
399
+ tiles.push({
400
+ id: primaryId,
401
+ type: primaryType,
402
+ frame: { x: areaX, y: areaY, width: areaWidth, height: primaryHeight },
403
+ fit: primaryFit,
404
+ cornerRadius,
405
+ zOrder: 0,
406
+ });
407
+
408
+ if (filmstripCount > 0) {
409
+ const stripY = areaY + primaryHeight + gap;
410
+ const tileWidth = (areaWidth - gap * Math.max(0, filmstripCount - 1)) / filmstripCount;
411
+ filmstripParticipants.forEach((p, i) => {
412
+ tiles.push({
413
+ id: p.id,
414
+ type: 'participant',
415
+ frame: {
416
+ x: areaX + i * (tileWidth + gap),
417
+ y: stripY,
418
+ width: tileWidth,
419
+ height: secondaryHeight,
420
+ },
421
+ fit: 'cover',
422
+ cornerRadius,
423
+ zOrder: i + 1,
424
+ });
425
+ });
426
+ }
427
+ }
428
+
429
+ return tiles;
430
+ }
431
+
432
+ // ===== Stable participant ordering ==========================================
433
+
434
+ function participantsStableOrder(
435
+ participants: SceneParticipant[],
436
+ localParticipantId: string,
437
+ ): SceneParticipant[] {
438
+ const remotes = participants.filter((p) => p.id !== localParticipantId);
439
+ const local = participants.filter((p) => p.id === localParticipantId);
440
+ return [...remotes, ...local];
441
+ }
442
+
443
+ // ===== Main computeLayout entry point =======================================
444
+
445
+ export function computeLayout(scene: CallScene): LayoutResult {
446
+ const mode = deriveMode(scene);
447
+ const group = deviceGroup(scene.viewportWidth, scene.viewportHeight);
448
+ const gap = LAYOUT_CONSTANTS.gap[group];
449
+ const outerPadding = LAYOUT_CONSTANTS.outerPadding[group];
450
+ const cornerRadius = LAYOUT_CONSTANTS.cornerRadius[group];
451
+
452
+ // Available area after safe-area insets
453
+ const availableX = scene.safeAreaInsets.left;
454
+ const availableY = scene.safeAreaInsets.top;
455
+ const availableWidth =
456
+ scene.viewportWidth - scene.safeAreaInsets.left - scene.safeAreaInsets.right;
457
+ const availableHeight =
458
+ scene.viewportHeight - scene.safeAreaInsets.top - scene.safeAreaInsets.bottom;
459
+
460
+ // Padded area for layouts with outerPadding
461
+ const paddedX = availableX + outerPadding;
462
+ const paddedY = availableY + outerPadding;
463
+ const paddedWidth = availableWidth - outerPadding * 2;
464
+ const paddedHeight = availableHeight - outerPadding * 2;
465
+
466
+ const localParticipant = scene.participants.find(
467
+ (p) => p.id === scene.localParticipantId,
468
+ );
469
+
470
+ switch (mode) {
471
+ // ----- solo: no remote tiles, local shown as PIP -----------------------
472
+ case 'solo': {
473
+ return {
474
+ mode,
475
+ tiles: [],
476
+ localPip: localParticipant
477
+ ? computePip(
478
+ localParticipant.id,
479
+ scene.viewportWidth,
480
+ scene.viewportHeight,
481
+ group,
482
+ localParticipant.videoAspectRatio,
483
+ )
484
+ : null,
485
+ };
486
+ }
487
+
488
+ // ----- pair: one tile fills area, other as PIP --------------------------
489
+ case 'pair': {
490
+ const remoteParticipant = scene.participants.find(
491
+ (p) => p.role === 'remote',
492
+ );
493
+
494
+ const dominant = scene.userPrefs.swappedLocalAndRemote
495
+ ? localParticipant
496
+ : remoteParticipant;
497
+ const pipParticipant = scene.userPrefs.swappedLocalAndRemote
498
+ ? remoteParticipant
499
+ : localParticipant;
500
+
501
+ const tiles: TileLayout[] = dominant
502
+ ? [
503
+ {
504
+ id: dominant.id,
505
+ type: 'participant',
506
+ frame: {
507
+ x: availableX,
508
+ y: availableY,
509
+ width: availableWidth,
510
+ height: availableHeight,
511
+ },
512
+ fit: scene.userPrefs.dominantFit,
513
+ cornerRadius: 0,
514
+ zOrder: 0,
515
+ },
516
+ ]
517
+ : [];
518
+
519
+ const localPip = pipParticipant
520
+ ? computePip(
521
+ pipParticipant.id,
522
+ scene.viewportWidth,
523
+ scene.viewportHeight,
524
+ group,
525
+ pipParticipant.videoAspectRatio,
526
+ )
527
+ : null;
528
+
529
+ return { mode, tiles, localPip };
530
+ }
531
+
532
+ // ----- grid: harmonic-mean optimized grid, local as PIP ----------------
533
+ case 'grid': {
534
+ const remoteParticipants = scene.participants.filter(
535
+ (p) => p.role === 'remote',
536
+ );
537
+
538
+ const stageTiles: StageTileSpec[] = remoteParticipants.map((p) => ({
539
+ cid: p.id,
540
+ aspectRatio: clampStageTileAspectRatio(p.videoAspectRatio),
541
+ }));
542
+
543
+ const rows = computeStageLayout(stageTiles, paddedWidth, paddedHeight, gap);
544
+
545
+ const absoluteTiles = gridTilesToAbsolute(
546
+ rows,
547
+ paddedX,
548
+ paddedY,
549
+ paddedWidth,
550
+ paddedHeight,
551
+ gap,
552
+ );
553
+
554
+ const tiles: TileLayout[] = absoluteTiles.map((t, index) => ({
555
+ id: t.cid,
556
+ type: 'participant' as const,
557
+ frame: t.frame,
558
+ fit: 'cover' as FitMode,
559
+ cornerRadius,
560
+ zOrder: index,
561
+ }));
562
+
563
+ const localPip = localParticipant
564
+ ? computePip(
565
+ localParticipant.id,
566
+ scene.viewportWidth,
567
+ scene.viewportHeight,
568
+ group,
569
+ localParticipant.videoAspectRatio,
570
+ )
571
+ : null;
572
+
573
+ return { mode, tiles, localPip };
574
+ }
575
+
576
+ // ----- focus: pinned participant primary + filmstrip --------------------
577
+ case 'focus': {
578
+ const pinnedParticipant = scene.participants.find(
579
+ (p) => p.id === scene.pinnedParticipantId,
580
+ );
581
+ if (!pinnedParticipant) {
582
+ // Fallback: if pinned participant not found, use grid
583
+ return computeLayout({ ...scene, pinnedParticipantId: null });
584
+ }
585
+
586
+ // Secondary: all participants except the pinned, in stable order (local last)
587
+ const secondaryParticipants = participantsStableOrder(
588
+ scene.participants.filter((p) => p.id !== pinnedParticipant.id),
589
+ scene.localParticipantId,
590
+ );
591
+
592
+ const tiles = computePrimaryWithFilmstrip(
593
+ pinnedParticipant.id,
594
+ 'participant',
595
+ scene.userPrefs.dominantFit,
596
+ secondaryParticipants,
597
+ paddedX,
598
+ paddedY,
599
+ paddedWidth,
600
+ paddedHeight,
601
+ gap,
602
+ cornerRadius,
603
+ group,
604
+ );
605
+
606
+ return { mode, tiles, localPip: null };
607
+ }
608
+
609
+ // ----- content: content source primary + filmstrip ----------------------
610
+ case 'content': {
611
+ if (!scene.contentSource) {
612
+ // Fallback: if content source disappeared, use grid
613
+ return computeLayout({ ...scene, contentSource: null });
614
+ }
615
+
616
+ // Secondary: all participants except content owner, in stable order (local last)
617
+ const secondaryParticipants = participantsStableOrder(
618
+ scene.participants.filter(p => p.id !== scene.contentSource!.ownerParticipantId),
619
+ scene.localParticipantId,
620
+ );
621
+
622
+ const tiles = computePrimaryWithFilmstrip(
623
+ scene.contentSource.ownerParticipantId + '_content',
624
+ 'contentSource',
625
+ scene.userPrefs.dominantFit,
626
+ secondaryParticipants,
627
+ paddedX,
628
+ paddedY,
629
+ paddedWidth,
630
+ paddedHeight,
631
+ gap,
632
+ cornerRadius,
633
+ group,
634
+ );
635
+
636
+ return { mode, tiles, localPip: null };
637
+ }
638
+ }
639
+ }