@tribe-nest/forge 3.42.1 → 3.50.0
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/package.json +2 -2
- package/src/data/queries/useCallChat.ts +162 -0
- package/src/data/queries/useCallRecording.ts +80 -0
- package/src/data/queries/useMembershipCalls.ts +212 -0
- package/src/i18n/de.json +74 -1
- package/src/i18n/en.json +74 -1
- package/src/index.ts +7 -0
- package/src/types/models.ts +58 -5
- package/src/ui/headless/memberHome/index.ts +4 -0
- package/src/ui/headless/memberHome/useMemberCalls.ts +150 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +29 -5
- package/src/ui/index.ts +3 -0
- package/src/ui/media/CallStage.tsx +691 -553
- package/src/ui/media/PreJoin.tsx +269 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +338 -5
- package/src/ui/media/_tests/callLayout.spec.ts +182 -0
- package/src/ui/media/_tests/callSignals.spec.ts +87 -0
- package/src/ui/media/_tests/callState.spec.ts +93 -1
- package/src/ui/media/_tests/fakeNode.ts +4 -1
- package/src/ui/media/callChat.ts +33 -0
- package/src/ui/media/callState.ts +125 -9
- package/src/ui/media/hooks/useCallSignals.ts +135 -0
- package/src/ui/media/hooks/useMeasuredWidth.ts +39 -0
- package/src/ui/media/index.ts +41 -1
- package/src/ui/media/panels/CallChatPanel.tsx +200 -0
- package/src/ui/media/panels/CallParticipantsPanel.tsx +125 -0
- package/src/ui/media/panels/CallSidePanel.tsx +152 -0
- package/src/ui/media/stage/CallCenterNotice.tsx +89 -0
- package/src/ui/media/stage/CallControlsBar.tsx +547 -0
- package/src/ui/media/stage/CallHealthPanel.tsx +132 -0
- package/src/ui/media/stage/CallToasts.tsx +49 -0
- package/src/ui/media/stage/CallTopBar.tsx +257 -0
- package/src/ui/media/stage/DeviceMenu.tsx +142 -0
- package/src/ui/media/stage/ReactionOverlay.tsx +38 -0
- package/src/ui/media/stage/SelfView.tsx +146 -0
- package/src/ui/media/stage/VideoTile.tsx +232 -0
- package/src/ui/media/stage/callLayout.ts +253 -0
- package/src/ui/media/stage/callSignals.ts +91 -0
- package/src/ui/media/stage/callSounds.ts +78 -0
- package/src/ui/media/stage/stageTheme.ts +50 -0
- package/src/ui/styled/CohortPage.tsx +15 -1
- package/src/ui/styled/CourseAccess.tsx +19 -6
- package/src/ui/styled/CourseCallsSection.tsx +99 -0
- package/src/ui/styled/FormRenderer.tsx +14 -4
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +163 -0
- package/src/ui/styled/forge-utilities.css +13 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +589 -0
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +135 -0
- package/src/ui/styled/members/index.ts +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { memo, useMemo, type CSSProperties } from "react";
|
|
2
|
+
import { Hand, MicOff, Pin, PinOff } from "lucide-react";
|
|
3
|
+
import { useRemoteTrack, useSpeakers } from "@tribe-nest/media-client/react";
|
|
4
|
+
|
|
5
|
+
import type { ForgeT } from "../../../i18n";
|
|
6
|
+
import type { CallTile } from "../callState";
|
|
7
|
+
import type { StageTokens } from "./stageTheme";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One participant on the stage.
|
|
11
|
+
*
|
|
12
|
+
* `React.memo`, and that is load-bearing: a gallery re-renders on every room
|
|
13
|
+
* change, and eight live videos re-mounting is a visible stutter. The tile
|
|
14
|
+
* object from `callTiles` is reference-stable while nothing about the person
|
|
15
|
+
* changed, which is what makes the memo hold.
|
|
16
|
+
*
|
|
17
|
+
* The MIC METER subscribes to the speaker levels ITSELF (`<MicMeter>`), so
|
|
18
|
+
* volume ticks re-render a 20px badge and never the tile.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type VideoTileProps = {
|
|
22
|
+
tile: CallTile;
|
|
23
|
+
tokens: StageTokens;
|
|
24
|
+
t: ForgeT;
|
|
25
|
+
/** `useTileViewport().observeTile`: registers this tile's element as
|
|
26
|
+
* rendering its producer, which is what the room narrows its subscriptions
|
|
27
|
+
* and picks simulcast layers from. Memoised HERE per producer id, because a
|
|
28
|
+
* fresh ref callback per render would re-run detach/attach every render. */
|
|
29
|
+
observeTile?: (producerId: string) => (element: HTMLElement | null) => void;
|
|
30
|
+
handRaisedAt?: number;
|
|
31
|
+
pinned?: boolean;
|
|
32
|
+
onTogglePin?: () => void;
|
|
33
|
+
/** Fill the parent instead of keeping an aspect ratio (speaker main, rails). */
|
|
34
|
+
fill?: boolean;
|
|
35
|
+
aspectRatio?: number;
|
|
36
|
+
/** Screen shares letterbox (`contain`): cropping a slide loses its content,
|
|
37
|
+
* where cropping a face only loses background. */
|
|
38
|
+
contain?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function VideoTileInner({
|
|
42
|
+
tile,
|
|
43
|
+
tokens,
|
|
44
|
+
t,
|
|
45
|
+
observeTile,
|
|
46
|
+
handRaisedAt,
|
|
47
|
+
pinned,
|
|
48
|
+
onTogglePin,
|
|
49
|
+
fill,
|
|
50
|
+
aspectRatio,
|
|
51
|
+
contain,
|
|
52
|
+
}: VideoTileProps) {
|
|
53
|
+
// Unconditional so the hook order matches between video and no-video tiles.
|
|
54
|
+
const { track, attach } = useRemoteTrack(tile.videoProducerId ?? "");
|
|
55
|
+
const showVideo = !!tile.videoProducerId && !!track && !tile.videoPaused;
|
|
56
|
+
const observe = useMemo(
|
|
57
|
+
() => (observeTile && tile.videoProducerId ? observeTile(tile.videoProducerId) : undefined),
|
|
58
|
+
[observeTile, tile.videoProducerId],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const frame: CSSProperties = {
|
|
62
|
+
position: "relative",
|
|
63
|
+
overflow: "hidden",
|
|
64
|
+
borderRadius: tokens.cornerRadius,
|
|
65
|
+
backgroundColor: tokens.stageSurface,
|
|
66
|
+
// Speaking is the FRAME, never a reshuffle: a grid that reorders itself
|
|
67
|
+
// every time somebody says "mm" moves the person out from under the eye.
|
|
68
|
+
border: `2px solid ${tile.isSpeaking ? tokens.accent : tokens.stageBorder}`,
|
|
69
|
+
display: "flex",
|
|
70
|
+
alignItems: "center",
|
|
71
|
+
justifyContent: "center",
|
|
72
|
+
boxSizing: "border-box",
|
|
73
|
+
...(fill
|
|
74
|
+
? { width: "100%", height: "100%", minHeight: 0 }
|
|
75
|
+
: { aspectRatio: String(aspectRatio ?? 16 / 9), maxHeight: "100%", width: "100%" }),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
ref={observe ?? null}
|
|
81
|
+
style={frame}
|
|
82
|
+
data-testid={`tile-${tile.identity}`}
|
|
83
|
+
data-speaking={tile.isSpeaking ? "true" : "false"}
|
|
84
|
+
data-source={tile.source}
|
|
85
|
+
>
|
|
86
|
+
{showVideo ? (
|
|
87
|
+
<video
|
|
88
|
+
ref={attach}
|
|
89
|
+
autoPlay
|
|
90
|
+
playsInline
|
|
91
|
+
muted
|
|
92
|
+
style={{ width: "100%", height: "100%", objectFit: contain || tile.source === "screen" ? "contain" : "cover" }}
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<Avatar name={tile.name} tokens={tokens} />
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{handRaisedAt !== undefined && (
|
|
99
|
+
<span
|
|
100
|
+
data-testid={`hand-${tile.identity}`}
|
|
101
|
+
aria-label={t("forge.call_stage.hand_raised_by", { name: tile.name })}
|
|
102
|
+
style={{
|
|
103
|
+
position: "absolute",
|
|
104
|
+
top: "0.5rem",
|
|
105
|
+
left: "0.5rem",
|
|
106
|
+
display: "inline-flex",
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
justifyContent: "center",
|
|
109
|
+
width: 26,
|
|
110
|
+
height: 26,
|
|
111
|
+
borderRadius: 9999,
|
|
112
|
+
backgroundColor: tokens.accent,
|
|
113
|
+
color: tokens.accentText,
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<Hand width={14} height={14} aria-hidden="true" />
|
|
117
|
+
</span>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{onTogglePin && (
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
onClick={onTogglePin}
|
|
124
|
+
aria-label={pinned ? t("forge.call_stage.unpin") : t("forge.call_stage.pin")}
|
|
125
|
+
style={{
|
|
126
|
+
position: "absolute",
|
|
127
|
+
top: "0.5rem",
|
|
128
|
+
right: "0.5rem",
|
|
129
|
+
display: "inline-flex",
|
|
130
|
+
alignItems: "center",
|
|
131
|
+
justifyContent: "center",
|
|
132
|
+
width: 26,
|
|
133
|
+
height: 26,
|
|
134
|
+
borderRadius: 9999,
|
|
135
|
+
border: "none",
|
|
136
|
+
cursor: "pointer",
|
|
137
|
+
backgroundColor: pinned ? tokens.accent : "rgba(0,0,0,0.5)",
|
|
138
|
+
color: pinned ? tokens.accentText : tokens.stageText,
|
|
139
|
+
}}
|
|
140
|
+
>
|
|
141
|
+
{pinned ? <PinOff width={14} height={14} aria-hidden="true" /> : <Pin width={14} height={14} aria-hidden="true" />}
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<span
|
|
146
|
+
style={{
|
|
147
|
+
position: "absolute",
|
|
148
|
+
left: "0.5rem",
|
|
149
|
+
bottom: "0.5rem",
|
|
150
|
+
maxWidth: "calc(100% - 1rem)",
|
|
151
|
+
display: "inline-flex",
|
|
152
|
+
alignItems: "center",
|
|
153
|
+
gap: "0.3rem",
|
|
154
|
+
overflow: "hidden",
|
|
155
|
+
fontSize: "0.75rem",
|
|
156
|
+
padding: "0.125rem 0.45rem",
|
|
157
|
+
borderRadius: tokens.cornerRadius,
|
|
158
|
+
backgroundColor: "rgba(0,0,0,0.55)",
|
|
159
|
+
color: tokens.stageText,
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
{tile.audioMuted && <MicOff width={12} height={12} aria-hidden="true" color={tokens.stageMuted} />}
|
|
163
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
164
|
+
{tile.source === "screen" ? t("forge.call_stage.screen_of", { name: tile.name }) : tile.name}
|
|
165
|
+
{!tile.hasAudio && <span style={{ color: tokens.stageMuted }}> {t("forge.call_stage.listening")}</span>}
|
|
166
|
+
</span>
|
|
167
|
+
{tile.hasAudio && !tile.audioMuted && <MicMeter identity={tile.identity} tokens={tokens} />}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const VideoTile = memo(VideoTileInner);
|
|
174
|
+
|
|
175
|
+
function Avatar({ name, tokens }: { name: string; tokens: StageTokens }) {
|
|
176
|
+
const initials = useMemo(
|
|
177
|
+
() =>
|
|
178
|
+
name
|
|
179
|
+
.split(/\s+/)
|
|
180
|
+
.map((part) => part[0] ?? "")
|
|
181
|
+
.join("")
|
|
182
|
+
.slice(0, 2)
|
|
183
|
+
.toUpperCase(),
|
|
184
|
+
[name],
|
|
185
|
+
);
|
|
186
|
+
return (
|
|
187
|
+
<span
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
style={{
|
|
190
|
+
display: "inline-flex",
|
|
191
|
+
alignItems: "center",
|
|
192
|
+
justifyContent: "center",
|
|
193
|
+
width: "clamp(2.5rem, 28%, 5rem)",
|
|
194
|
+
aspectRatio: "1",
|
|
195
|
+
borderRadius: 9999,
|
|
196
|
+
backgroundColor: tokens.stageChrome,
|
|
197
|
+
color: tokens.stageText,
|
|
198
|
+
fontWeight: 600,
|
|
199
|
+
fontSize: "1rem",
|
|
200
|
+
}}
|
|
201
|
+
>
|
|
202
|
+
{initials || "?"}
|
|
203
|
+
</span>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The level meter, self-subscribed. This little component reads the speaker
|
|
209
|
+
* list so the TILE does not: levels tick a few times a second while anyone
|
|
210
|
+
* talks, and a badge re-rendering is free where a video element is not.
|
|
211
|
+
*/
|
|
212
|
+
export function MicMeter({ identity, tokens }: { identity: string; tokens: StageTokens }) {
|
|
213
|
+
const speakers = useSpeakers();
|
|
214
|
+
const level = speakers.find((s) => s.identity === identity)?.volumeDb;
|
|
215
|
+
// -60 dB (the node's floor) to roughly -20 (loud) mapped onto three bars.
|
|
216
|
+
const bars = level === undefined ? 0 : level >= -30 ? 3 : level >= -45 ? 2 : 1;
|
|
217
|
+
return (
|
|
218
|
+
<span aria-hidden="true" style={{ display: "inline-flex", alignItems: "flex-end", gap: 1, height: 10 }}>
|
|
219
|
+
{[1, 2, 3].map((bar) => (
|
|
220
|
+
<span
|
|
221
|
+
key={bar}
|
|
222
|
+
style={{
|
|
223
|
+
width: 3,
|
|
224
|
+
height: 3 + bar * 2,
|
|
225
|
+
borderRadius: 1,
|
|
226
|
+
backgroundColor: bars >= bar ? tokens.accent : "rgba(255,255,255,0.25)",
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
))}
|
|
230
|
+
</span>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type { SpeakerInfo } from "@tribe-nest/media-client";
|
|
2
|
+
|
|
3
|
+
import type { CallTile } from "../callState";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The stage's layout engine, as pure functions.
|
|
7
|
+
*
|
|
8
|
+
* Which tiles are on screen, in what arrangement, is the decision that drives
|
|
9
|
+
* everything else: the viewport report (and with it what the room subscribes
|
|
10
|
+
* to and which simulcast layer each tile pulls) is derived from what this
|
|
11
|
+
* says is rendered. So it lives here, exhaustively testable, and the
|
|
12
|
+
* components only draw its answer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What the person chose with the view toggle. */
|
|
16
|
+
export type ViewChoice = "gallery" | "speaker";
|
|
17
|
+
|
|
18
|
+
export type LayoutMode = "duo" | "gallery" | "speaker" | "presenting";
|
|
19
|
+
|
|
20
|
+
/** Gallery pages at 8: an even grid that fits a laptop screen, and one under
|
|
21
|
+
* the plane's 9-video active-set cap, so every visible tile can be live. */
|
|
22
|
+
export const GALLERY_PAGE_SIZE = 8;
|
|
23
|
+
|
|
24
|
+
/** The presenting and speaker rails show 4 tiles, dominant speaker first. */
|
|
25
|
+
export const RAIL_PAGE_SIZE = 4;
|
|
26
|
+
|
|
27
|
+
export type StageLayout = {
|
|
28
|
+
mode: LayoutMode;
|
|
29
|
+
/** The big one: the share in presenting, the speaker in speaker view. */
|
|
30
|
+
main?: CallTile;
|
|
31
|
+
/** Who is presenting, when a share is on the stage. */
|
|
32
|
+
presenter?: { identity: string; name: string };
|
|
33
|
+
/** The tiles the current page renders (the rail, in speaker/presenting). */
|
|
34
|
+
pageTiles: readonly CallTile[];
|
|
35
|
+
page: number;
|
|
36
|
+
pageCount: number;
|
|
37
|
+
columns: number;
|
|
38
|
+
rows: number;
|
|
39
|
+
/** People currently speaking whose tile is NOT on this page. */
|
|
40
|
+
offPageSpeakerNames: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Grid shape for N tiles: even rectangles, never a ragged square.
|
|
45
|
+
*
|
|
46
|
+
* 1 = full bleed, 2 = side by side (the two-portrait duo the 1:1 case wants),
|
|
47
|
+
* 3-4 = 2x2, 5-6 = 3x2, 7-8 = 4x2. Columns cap at 4: a 14-inch screen fits
|
|
48
|
+
* four faces across before they stop being faces.
|
|
49
|
+
*/
|
|
50
|
+
export function gridShape(count: number): { columns: number; rows: number } {
|
|
51
|
+
if (count <= 1) return { columns: 1, rows: 1 };
|
|
52
|
+
if (count === 2) return { columns: 2, rows: 1 };
|
|
53
|
+
if (count <= 4) return { columns: 2, rows: 2 };
|
|
54
|
+
if (count <= 6) return { columns: 3, rows: 2 };
|
|
55
|
+
return { columns: 4, rows: 2 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The same grid on a PHONE, where a face is only recognisable in PORTRAIT.
|
|
60
|
+
*
|
|
61
|
+
* A phone is tall and thin, so the desktop's wide rectangles read as a strip
|
|
62
|
+
* of letterbox slivers. One column below three people (a single stacked
|
|
63
|
+
* portrait each), two columns above that - the shape a phone actually wants,
|
|
64
|
+
* and what Zoom's mobile app does. The count still decides the shape; the
|
|
65
|
+
* device decides which shape function answers.
|
|
66
|
+
*/
|
|
67
|
+
export function gridShapeNarrow(count: number): { columns: number; rows: number } {
|
|
68
|
+
if (count <= 1) return { columns: 1, rows: 1 };
|
|
69
|
+
if (count === 2) return { columns: 1, rows: 2 };
|
|
70
|
+
const columns = 2;
|
|
71
|
+
return { columns, rows: Math.ceil(count / columns) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Clamps the page so people leaving mid-call cannot strand you on an empty one. */
|
|
75
|
+
export function paginate<T>(items: readonly T[], page: number, size: number): { items: readonly T[]; page: number; pageCount: number } {
|
|
76
|
+
const pageCount = Math.max(1, Math.ceil(items.length / size));
|
|
77
|
+
const clamped = Math.min(Math.max(0, page), pageCount - 1);
|
|
78
|
+
return { items: items.slice(clamped * size, clamped * size + size), page: clamped, pageCount };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The dominant speaker, held for `holdMs` so a grid does not flap on "mm".
|
|
83
|
+
*
|
|
84
|
+
* The node's ranking moves a few times a second; a layout that follows it
|
|
85
|
+
* verbatim moves the person you are looking at out from under your eye. A
|
|
86
|
+
* new leader takes over only once the incumbent's hold has run out.
|
|
87
|
+
*/
|
|
88
|
+
export function pickDominant(input: {
|
|
89
|
+
speakers: readonly SpeakerInfo[];
|
|
90
|
+
previous: string | null;
|
|
91
|
+
previousSince: number;
|
|
92
|
+
now: number;
|
|
93
|
+
holdMs?: number;
|
|
94
|
+
}): { identity: string | null; since: number } {
|
|
95
|
+
const holdMs = input.holdMs ?? 1500;
|
|
96
|
+
const leader = input.speakers[0]?.identity ?? null;
|
|
97
|
+
if (leader === null) return { identity: input.previous, since: input.previousSince };
|
|
98
|
+
if (leader === input.previous) return { identity: input.previous, since: input.previousSince };
|
|
99
|
+
if (input.previous !== null && input.now - input.previousSince < holdMs) {
|
|
100
|
+
return { identity: input.previous, since: input.previousSince };
|
|
101
|
+
}
|
|
102
|
+
return { identity: leader, since: input.now };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The share on stage, when there is one. First by key for determinism. */
|
|
106
|
+
export function presentingTile(tiles: readonly CallTile[]): CallTile | undefined {
|
|
107
|
+
return [...tiles].sort((a, b) => a.key.localeCompare(b.key)).find((t) => t.source === "screen" && !!t.videoProducerId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type ResolveLayoutInput = {
|
|
111
|
+
/** Remote tiles from `callTiles`, camera and screen slots alike. */
|
|
112
|
+
tiles: readonly CallTile[];
|
|
113
|
+
view: ViewChoice;
|
|
114
|
+
page: number;
|
|
115
|
+
/** A tile key the viewer pinned; forces speaker view on that tile. */
|
|
116
|
+
pinnedKey: string | null;
|
|
117
|
+
/** From `pickDominant`. Orders the rail and picks the speaker-view main. */
|
|
118
|
+
dominantIdentity: string | null;
|
|
119
|
+
/** THIS viewer is sharing: their own share takes the stage too. */
|
|
120
|
+
localPresenting: boolean;
|
|
121
|
+
/** The stage is phone-narrow, so tiles stack as portraits rather than
|
|
122
|
+
* sitting side by side. Defaults to false: desktop behaviour is unchanged
|
|
123
|
+
* and every existing caller that omits it gets exactly today's layout. */
|
|
124
|
+
narrow?: boolean;
|
|
125
|
+
pageSize?: number;
|
|
126
|
+
railSize?: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Precedence, highest first: a screen share on the stage (anyone's, including
|
|
131
|
+
* the viewer's own), then a pinned tile, then the chosen view. A duo (exactly
|
|
132
|
+
* one remote person) renders as two large portraits whatever the toggle says,
|
|
133
|
+
* because a gallery of two with a pager is a joke and Speaker view of one
|
|
134
|
+
* person is the same picture with more chrome.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveLayout(input: ResolveLayoutInput): StageLayout {
|
|
137
|
+
const pageSize = input.pageSize ?? GALLERY_PAGE_SIZE;
|
|
138
|
+
const railSize = input.railSize ?? RAIL_PAGE_SIZE;
|
|
139
|
+
const cameraTiles = input.tiles.filter((t) => t.source === "camera");
|
|
140
|
+
const remoteIdentities = new Set(cameraTiles.map((t) => t.identity));
|
|
141
|
+
|
|
142
|
+
/** Dominant speaker first, then the existing (stable) order. Membership
|
|
143
|
+
* changes page composition; order within a page costs nothing. */
|
|
144
|
+
const byDominance = (tiles: readonly CallTile[]): CallTile[] =>
|
|
145
|
+
[...tiles].sort((a, b) => {
|
|
146
|
+
const aDominant = a.identity === input.dominantIdentity ? 0 : 1;
|
|
147
|
+
const bDominant = b.identity === input.dominantIdentity ? 0 : 1;
|
|
148
|
+
return aDominant - bDominant;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const share = presentingTile(input.tiles);
|
|
152
|
+
if (share || input.localPresenting) {
|
|
153
|
+
const rail = paginate(byDominance(cameraTiles), input.page, railSize);
|
|
154
|
+
return {
|
|
155
|
+
mode: "presenting",
|
|
156
|
+
...(share ? { main: share, presenter: { identity: share.identity, name: share.name } } : {}),
|
|
157
|
+
pageTiles: rail.items,
|
|
158
|
+
page: rail.page,
|
|
159
|
+
pageCount: rail.pageCount,
|
|
160
|
+
columns: 1,
|
|
161
|
+
rows: rail.items.length,
|
|
162
|
+
offPageSpeakerNames: offPage(cameraTiles, rail.items, input.dominantIdentity),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pinned = input.pinnedKey ? cameraTiles.find((t) => t.key === input.pinnedKey) : undefined;
|
|
167
|
+
if (pinned) {
|
|
168
|
+
const rest = cameraTiles.filter((t) => t.key !== pinned.key);
|
|
169
|
+
const rail = paginate(byDominance(rest), input.page, railSize);
|
|
170
|
+
return {
|
|
171
|
+
mode: "speaker",
|
|
172
|
+
main: pinned,
|
|
173
|
+
pageTiles: rail.items,
|
|
174
|
+
page: rail.page,
|
|
175
|
+
pageCount: rail.pageCount,
|
|
176
|
+
columns: rail.items.length,
|
|
177
|
+
rows: 1,
|
|
178
|
+
offPageSpeakerNames: offPage(cameraTiles, [pinned, ...rail.items], input.dominantIdentity),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (remoteIdentities.size === 1) {
|
|
183
|
+
// ALL of their camera tiles, not the first: an older publisher can carry
|
|
184
|
+
// two unlabeled videos, and hiding one in the very layout built for
|
|
185
|
+
// looking at one person would be the wrong place to lose it.
|
|
186
|
+
//
|
|
187
|
+
// The `+1` is the local self-view's cell. On a phone the two stack as
|
|
188
|
+
// portraits (one column, a row each); on a desktop they sit side by side.
|
|
189
|
+
// Two faces side by side on a phone is the exact "looks way off" this
|
|
190
|
+
// fixes: each becomes a thin letterbox of the middle of a face.
|
|
191
|
+
const cells = cameraTiles.length + 1;
|
|
192
|
+
return {
|
|
193
|
+
mode: "duo",
|
|
194
|
+
pageTiles: cameraTiles,
|
|
195
|
+
page: 0,
|
|
196
|
+
pageCount: 1,
|
|
197
|
+
columns: input.narrow ? 1 : cells,
|
|
198
|
+
rows: input.narrow ? cells : 1,
|
|
199
|
+
offPageSpeakerNames: [],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (input.view === "speaker" && cameraTiles.length > 0) {
|
|
204
|
+
const ordered = byDominance(cameraTiles);
|
|
205
|
+
const main = ordered[0]!;
|
|
206
|
+
const rail = paginate(
|
|
207
|
+
ordered.filter((t) => t.key !== main.key),
|
|
208
|
+
input.page,
|
|
209
|
+
railSize,
|
|
210
|
+
);
|
|
211
|
+
return {
|
|
212
|
+
mode: "speaker",
|
|
213
|
+
main,
|
|
214
|
+
pageTiles: rail.items,
|
|
215
|
+
page: rail.page,
|
|
216
|
+
pageCount: rail.pageCount,
|
|
217
|
+
columns: rail.items.length,
|
|
218
|
+
rows: 1,
|
|
219
|
+
offPageSpeakerNames: offPage(cameraTiles, [main, ...rail.items], input.dominantIdentity),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const pageResult = paginate(cameraTiles, input.page, pageSize);
|
|
224
|
+
// +1 cell for the local self-view on the first page of a small call is the
|
|
225
|
+
// component's business; the shape is for the REMOTE tiles it was given.
|
|
226
|
+
const count = Math.max(1, pageResult.items.length);
|
|
227
|
+
const shape = input.narrow ? gridShapeNarrow(count) : gridShape(count);
|
|
228
|
+
return {
|
|
229
|
+
mode: "gallery",
|
|
230
|
+
pageTiles: pageResult.items,
|
|
231
|
+
page: pageResult.page,
|
|
232
|
+
pageCount: pageResult.pageCount,
|
|
233
|
+
columns: shape.columns,
|
|
234
|
+
rows: shape.rows,
|
|
235
|
+
offPageSpeakerNames: offPage(cameraTiles, pageResult.items, input.dominantIdentity),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Names of people SPEAKING whose tile the current page does not render. */
|
|
240
|
+
function offPage(
|
|
241
|
+
all: readonly CallTile[],
|
|
242
|
+
visible: readonly CallTile[],
|
|
243
|
+
dominantIdentity: string | null,
|
|
244
|
+
): readonly string[] {
|
|
245
|
+
const shown = new Set(visible.map((t) => t.identity));
|
|
246
|
+
const names: string[] = [];
|
|
247
|
+
for (const tile of all) {
|
|
248
|
+
if (shown.has(tile.identity)) continue;
|
|
249
|
+
if (!tile.isSpeaking && tile.identity !== dominantIdentity) continue;
|
|
250
|
+
if (!names.includes(tile.name)) names.push(tile.name);
|
|
251
|
+
}
|
|
252
|
+
return names;
|
|
253
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { RaisedHand } from "@tribe-nest/media-client";
|
|
2
|
+
import type { Peer } from "@tribe-nest/media-protocol";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The stage's ephemeral-signal arithmetic: hands, flying reactions, toasts
|
|
6
|
+
* and the unread badge. Pure, because every one of these has an edge (a
|
|
7
|
+
* burst of joins, a reaction flood, a hand queue that must not reshuffle)
|
|
8
|
+
* that is a two-line test here and a flaky screen anywhere else.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The reactions a participant may send. A closed list on BOTH ends: the
|
|
12
|
+
* picker offers these, and incoming broadcasts outside it are dropped, so a
|
|
13
|
+
* hostile peer cannot fly arbitrary text across everyone's screen. */
|
|
14
|
+
export const REACTION_EMOJI = ["👍", "👏", "❤️", "😂", "😮", "🎉"] as const;
|
|
15
|
+
export type ReactionEmoji = (typeof REACTION_EMOJI)[number];
|
|
16
|
+
|
|
17
|
+
export function isKnownReaction(emoji: unknown): emoji is ReactionEmoji {
|
|
18
|
+
return typeof emoji === "string" && (REACTION_EMOJI as readonly string[]).includes(emoji);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type FlyingReaction = {
|
|
22
|
+
/** Unique per animation instance. */
|
|
23
|
+
id: string;
|
|
24
|
+
emoji: ReactionEmoji;
|
|
25
|
+
identity: string;
|
|
26
|
+
/** 0..1, where across the stage it rises. Derived from the id so it is
|
|
27
|
+
* stable per instance without a random source in a pure module. */
|
|
28
|
+
lane: number;
|
|
29
|
+
at: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Minimum ms between OUR sends. The node rate-limits too; this keeps an
|
|
33
|
+
* enthusiastic key-holder from ever meeting that refusal. */
|
|
34
|
+
export const REACTION_SEND_GATE_MS = 500;
|
|
35
|
+
|
|
36
|
+
export function reactionGate(lastSentAt: number | null, now: number, gateMs = REACTION_SEND_GATE_MS): boolean {
|
|
37
|
+
return lastSentAt === null || now - lastSentAt >= gateMs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Drop reactions past their animation, and cap how many fly at once: a
|
|
41
|
+
* hundred simultaneous emoji is a denial of the actual call. Newest win. */
|
|
42
|
+
export function cullReactions(list: readonly FlyingReaction[], now: number, ttlMs = 4000, max = 30): readonly FlyingReaction[] {
|
|
43
|
+
const alive = list.filter((r) => now - r.at < ttlMs);
|
|
44
|
+
return alive.length <= max ? alive : alive.slice(alive.length - max);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A stable 0..1 lane from an id, so the module needs no random source. */
|
|
48
|
+
export function laneFor(id: string): number {
|
|
49
|
+
let hash = 0;
|
|
50
|
+
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
|
51
|
+
return (hash % 997) / 997;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Oldest first: the hand queue's order IS its meaning. The client re-sorts
|
|
55
|
+
* even though the wire is sorted, so a merge bug upstream cannot reorder a
|
|
56
|
+
* queue a host is calling on. */
|
|
57
|
+
export function orderHands(hands: readonly RaisedHand[]): readonly RaisedHand[] {
|
|
58
|
+
return [...hands].sort((a, b) => a.raisedAt - b.raisedAt || a.identity.localeCompare(b.identity));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type PeerChange = { identity: string; name: string; kind: "joined" | "left" };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Who joined and who left between two peer snapshots, for the toasts. The
|
|
65
|
+
* caller collapses a burst; this only reports the truth of one transition.
|
|
66
|
+
*/
|
|
67
|
+
export function diffPeers(previous: readonly Peer[], next: readonly Peer[]): readonly PeerChange[] {
|
|
68
|
+
const before = new Map(previous.map((p) => [p.identity, p]));
|
|
69
|
+
const after = new Map(next.map((p) => [p.identity, p]));
|
|
70
|
+
const changes: PeerChange[] = [];
|
|
71
|
+
for (const peer of next) {
|
|
72
|
+
if (peer.kind === "egress") continue;
|
|
73
|
+
if (!before.has(peer.identity)) changes.push({ identity: peer.identity, name: peer.name ?? peer.identity, kind: "joined" });
|
|
74
|
+
}
|
|
75
|
+
for (const peer of previous) {
|
|
76
|
+
if (peer.kind === "egress") continue;
|
|
77
|
+
if (!after.has(peer.identity)) changes.push({ identity: peer.identity, name: peer.name ?? peer.identity, kind: "left" });
|
|
78
|
+
}
|
|
79
|
+
return changes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Messages newer than the last one seen. Counted by position, not timestamp:
|
|
84
|
+
* ids are opaque and clocks are nobody's friend, but the list arrives in
|
|
85
|
+
* order and "seen" is a position in it.
|
|
86
|
+
*/
|
|
87
|
+
export function unreadCount(messageIds: readonly string[], lastSeenId: string | null): number {
|
|
88
|
+
if (lastSeenId === null) return messageIds.length;
|
|
89
|
+
const index = messageIds.lastIndexOf(lastSeenId);
|
|
90
|
+
return index === -1 ? messageIds.length : messageIds.length - index - 1;
|
|
91
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join and leave chimes, synthesized. No asset, no network: two short sine
|
|
3
|
+
* envelopes from WebAudio, quiet on purpose.
|
|
4
|
+
*
|
|
5
|
+
* The AudioContext is created on first USE from a user gesture (the Join
|
|
6
|
+
* click via PreJoin, or any control press): a context created outside one is
|
|
7
|
+
* suspended by autoplay policy and every chime is silent with nothing in the
|
|
8
|
+
* console. Where the context cannot run (jsdom, an embedded surface with no
|
|
9
|
+
* gesture) every call is a no-op.
|
|
10
|
+
*/
|
|
11
|
+
export type CallSounds = {
|
|
12
|
+
/** Call from a user gesture, once. Safe to call repeatedly. */
|
|
13
|
+
unlock(): void;
|
|
14
|
+
join(): void;
|
|
15
|
+
leave(): void;
|
|
16
|
+
/** A hand went up somewhere in the room. Everyone's client rings it. */
|
|
17
|
+
raiseHand(): void;
|
|
18
|
+
setMuted(muted: boolean): void;
|
|
19
|
+
readonly muted: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createCallSounds(): CallSounds {
|
|
23
|
+
let context: AudioContext | null = null;
|
|
24
|
+
let muted = false;
|
|
25
|
+
|
|
26
|
+
const ensure = (): AudioContext | null => {
|
|
27
|
+
if (context) return context;
|
|
28
|
+
const Ctor =
|
|
29
|
+
typeof AudioContext !== "undefined"
|
|
30
|
+
? AudioContext
|
|
31
|
+
: (globalThis as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
32
|
+
if (!Ctor) return null;
|
|
33
|
+
try {
|
|
34
|
+
context = new Ctor();
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return context;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const chime = (frequencies: number[]) => {
|
|
42
|
+
if (muted) return;
|
|
43
|
+
const ctx = context;
|
|
44
|
+
if (!ctx || ctx.state !== "running") return;
|
|
45
|
+
const now = ctx.currentTime;
|
|
46
|
+
frequencies.forEach((frequency, index) => {
|
|
47
|
+
const osc = ctx.createOscillator();
|
|
48
|
+
const gain = ctx.createGain();
|
|
49
|
+
osc.type = "sine";
|
|
50
|
+
osc.frequency.value = frequency;
|
|
51
|
+
// Quiet, and enveloped: a click-free 180ms ping.
|
|
52
|
+
gain.gain.setValueAtTime(0, now + index * 0.12);
|
|
53
|
+
gain.gain.linearRampToValueAtTime(0.06, now + index * 0.12 + 0.02);
|
|
54
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + index * 0.12 + 0.18);
|
|
55
|
+
osc.connect(gain).connect(ctx.destination);
|
|
56
|
+
osc.start(now + index * 0.12);
|
|
57
|
+
osc.stop(now + index * 0.12 + 0.2);
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
unlock() {
|
|
63
|
+
const ctx = ensure();
|
|
64
|
+
if (ctx?.state === "suspended") void ctx.resume().catch(() => undefined);
|
|
65
|
+
},
|
|
66
|
+
join: () => chime([660, 880]),
|
|
67
|
+
leave: () => chime([660, 440]),
|
|
68
|
+
// A brighter, rising three-step so a raised hand is not mistaken for a
|
|
69
|
+
// join: attention, not arrival.
|
|
70
|
+
raiseHand: () => chime([784, 988, 1319]),
|
|
71
|
+
setMuted(value: boolean) {
|
|
72
|
+
muted = value;
|
|
73
|
+
},
|
|
74
|
+
get muted() {
|
|
75
|
+
return muted;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|