@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.
- package/dist/ConsoleLogger.d.ts +6 -0
- package/dist/ConsoleLogger.d.ts.map +1 -0
- package/dist/ConsoleLogger.js +21 -0
- package/dist/ConsoleLogger.js.map +1 -0
- package/dist/RoomWatcher.d.ts +34 -0
- package/dist/RoomWatcher.d.ts.map +1 -0
- package/dist/RoomWatcher.js +103 -0
- package/dist/RoomWatcher.js.map +1 -0
- package/dist/SerenadaCore.d.ts +47 -0
- package/dist/SerenadaCore.d.ts.map +1 -0
- package/dist/SerenadaCore.js +141 -0
- package/dist/SerenadaCore.js.map +1 -0
- package/dist/SerenadaDiagnostics.d.ts +49 -0
- package/dist/SerenadaDiagnostics.d.ts.map +1 -0
- package/dist/SerenadaDiagnostics.js +421 -0
- package/dist/SerenadaDiagnostics.js.map +1 -0
- package/dist/SerenadaServerProvider.d.ts +48 -0
- package/dist/SerenadaServerProvider.d.ts.map +1 -0
- package/dist/SerenadaServerProvider.js +296 -0
- package/dist/SerenadaServerProvider.js.map +1 -0
- package/dist/SerenadaSession.d.ts +180 -0
- package/dist/SerenadaSession.d.ts.map +1 -0
- package/dist/SerenadaSession.js +1082 -0
- package/dist/SerenadaSession.js.map +1 -0
- package/dist/SignalingProvider.d.ts +132 -0
- package/dist/SignalingProvider.d.ts.map +1 -0
- package/dist/SignalingProvider.js +50 -0
- package/dist/SignalingProvider.js.map +1 -0
- package/dist/api/roomApi.d.ts +2 -0
- package/dist/api/roomApi.d.ts.map +1 -0
- package/dist/api/roomApi.js +14 -0
- package/dist/api/roomApi.js.map +1 -0
- package/dist/cameraModes.d.ts +13 -0
- package/dist/cameraModes.d.ts.map +1 -0
- package/dist/cameraModes.js +35 -0
- package/dist/cameraModes.js.map +1 -0
- package/dist/configValidation.d.ts +10 -0
- package/dist/configValidation.d.ts.map +1 -0
- package/dist/configValidation.js +24 -0
- package/dist/configValidation.js.map +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -0
- package/dist/formatError.d.ts +3 -0
- package/dist/formatError.d.ts.map +1 -0
- package/dist/formatError.js +7 -0
- package/dist/formatError.js.map +1 -0
- package/dist/iceServers.d.ts +2 -0
- package/dist/iceServers.d.ts.map +1 -0
- package/dist/iceServers.js +21 -0
- package/dist/iceServers.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/computeLayout.d.ts +81 -0
- package/dist/layout/computeLayout.d.ts.map +1 -0
- package/dist/layout/computeLayout.js +380 -0
- package/dist/layout/computeLayout.js.map +1 -0
- package/dist/media/AudioLevelMonitor.d.ts +51 -0
- package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
- package/dist/media/AudioLevelMonitor.js +179 -0
- package/dist/media/AudioLevelMonitor.js.map +1 -0
- package/dist/media/MediaEngine.d.ts +137 -0
- package/dist/media/MediaEngine.d.ts.map +1 -0
- package/dist/media/MediaEngine.js +1224 -0
- package/dist/media/MediaEngine.js.map +1 -0
- package/dist/media/callStats.d.ts +16 -0
- package/dist/media/callStats.d.ts.map +1 -0
- package/dist/media/callStats.js +214 -0
- package/dist/media/callStats.js.map +1 -0
- package/dist/media/localVideoRecovery.d.ts +16 -0
- package/dist/media/localVideoRecovery.d.ts.map +1 -0
- package/dist/media/localVideoRecovery.js +14 -0
- package/dist/media/localVideoRecovery.js.map +1 -0
- package/dist/recoveryStorage.d.ts +33 -0
- package/dist/recoveryStorage.d.ts.map +1 -0
- package/dist/recoveryStorage.js +88 -0
- package/dist/recoveryStorage.js.map +1 -0
- package/dist/serverUrls.d.ts +8 -0
- package/dist/serverUrls.d.ts.map +1 -0
- package/dist/serverUrls.js +65 -0
- package/dist/serverUrls.js.map +1 -0
- package/dist/signaling/SignalingEngine.d.ts +126 -0
- package/dist/signaling/SignalingEngine.d.ts.map +1 -0
- package/dist/signaling/SignalingEngine.js +720 -0
- package/dist/signaling/SignalingEngine.js.map +1 -0
- package/dist/signaling/payloads.d.ts +76 -0
- package/dist/signaling/payloads.d.ts.map +1 -0
- package/dist/signaling/payloads.js +160 -0
- package/dist/signaling/payloads.js.map +1 -0
- package/dist/signaling/roomStatuses.d.ts +9 -0
- package/dist/signaling/roomStatuses.d.ts.map +1 -0
- package/dist/signaling/roomStatuses.js +71 -0
- package/dist/signaling/roomStatuses.js.map +1 -0
- package/dist/signaling/transportConfig.d.ts +3 -0
- package/dist/signaling/transportConfig.d.ts.map +1 -0
- package/dist/signaling/transportConfig.js +27 -0
- package/dist/signaling/transportConfig.js.map +1 -0
- package/dist/signaling/transports/index.d.ts +13 -0
- package/dist/signaling/transports/index.d.ts.map +1 -0
- package/dist/signaling/transports/index.js +11 -0
- package/dist/signaling/transports/index.js.map +1 -0
- package/dist/signaling/transports/sse.d.ts +26 -0
- package/dist/signaling/transports/sse.d.ts.map +1 -0
- package/dist/signaling/transports/sse.js +131 -0
- package/dist/signaling/transports/sse.js.map +1 -0
- package/dist/signaling/transports/types.d.ts +17 -0
- package/dist/signaling/transports/types.d.ts.map +1 -0
- package/dist/signaling/transports/types.js +2 -0
- package/dist/signaling/transports/types.js.map +1 -0
- package/dist/signaling/transports/ws.d.ts +21 -0
- package/dist/signaling/transports/ws.d.ts.map +1 -0
- package/dist/signaling/transports/ws.js +93 -0
- package/dist/signaling/transports/ws.js.map +1 -0
- package/dist/signaling/types.d.ts +53 -0
- package/dist/signaling/types.d.ts.map +1 -0
- package/dist/signaling/types.js +2 -0
- package/dist/signaling/types.js.map +1 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/ConsoleLogger.ts +14 -0
- package/src/RoomWatcher.ts +127 -0
- package/src/SerenadaCore.ts +163 -0
- package/src/SerenadaDiagnostics.ts +485 -0
- package/src/SerenadaServerProvider.ts +362 -0
- package/src/SerenadaSession.ts +1258 -0
- package/src/SignalingProvider.ts +207 -0
- package/src/api/roomApi.ts +16 -0
- package/src/cameraModes.ts +34 -0
- package/src/configValidation.ts +35 -0
- package/src/constants.ts +77 -0
- package/src/formatError.ts +5 -0
- package/src/iceServers.ts +20 -0
- package/src/index.ts +155 -0
- package/src/layout/computeLayout.ts +639 -0
- package/src/media/AudioLevelMonitor.ts +190 -0
- package/src/media/MediaEngine.ts +1183 -0
- package/src/media/callStats.ts +260 -0
- package/src/media/localVideoRecovery.ts +39 -0
- package/src/recoveryStorage.ts +101 -0
- package/src/serverUrls.ts +69 -0
- package/src/signaling/SignalingEngine.ts +762 -0
- package/src/signaling/payloads.ts +215 -0
- package/src/signaling/roomStatuses.ts +89 -0
- package/src/signaling/transportConfig.ts +30 -0
- package/src/signaling/transports/index.ts +26 -0
- package/src/signaling/transports/sse.ts +146 -0
- package/src/signaling/transports/types.ts +19 -0
- package/src/signaling/transports/ws.ts +108 -0
- package/src/signaling/types.ts +68 -0
- 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
|
+
}
|