@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
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef,
|
|
2
|
-
import {
|
|
3
|
-
AlertTriangle,
|
|
4
|
-
Loader2,
|
|
5
|
-
Mic,
|
|
6
|
-
MicOff,
|
|
7
|
-
MonitorUp,
|
|
8
|
-
MonitorX,
|
|
9
|
-
PhoneOff,
|
|
10
|
-
RefreshCw,
|
|
11
|
-
Video,
|
|
12
|
-
VideoOff,
|
|
13
|
-
} from "lucide-react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
14
3
|
|
|
15
4
|
import {
|
|
16
5
|
useActiveSpeakers,
|
|
17
|
-
useGrants,
|
|
18
6
|
useLocalMedia,
|
|
19
7
|
useLocalPublications,
|
|
20
8
|
useMediaRoom,
|
|
@@ -22,107 +10,195 @@ import {
|
|
|
22
10
|
useRecording,
|
|
23
11
|
useRemoteTrack,
|
|
24
12
|
useRoomState,
|
|
13
|
+
useSpeakers,
|
|
14
|
+
useTileViewport,
|
|
25
15
|
useVisibleProducers,
|
|
26
|
-
type LocalSource,
|
|
27
16
|
} from "@tribe-nest/media-client/react";
|
|
28
17
|
|
|
29
18
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
30
|
-
import { useThemeTokens
|
|
31
|
-
import {
|
|
19
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
20
|
+
import type { CallStageChat } from "./callChat";
|
|
32
21
|
import {
|
|
33
22
|
callHeadCount,
|
|
34
23
|
callStatus,
|
|
35
24
|
callTiles,
|
|
36
|
-
canPublishSource,
|
|
37
25
|
remoteAudioProducerIds,
|
|
38
|
-
|
|
26
|
+
stageNotice,
|
|
39
27
|
type CallTile,
|
|
40
28
|
} from "./callState";
|
|
29
|
+
import { useFlyingReactions, useRaisedHands } from "./hooks/useCallSignals";
|
|
30
|
+
import { CallSidePanel, type SidePanelTab } from "./panels/CallSidePanel";
|
|
31
|
+
import { resolveLayout, GALLERY_PAGE_SIZE, pickDominant, type StageLayout, type ViewChoice } from "./stage/callLayout";
|
|
32
|
+
import { diffPeers, unreadCount } from "./stage/callSignals";
|
|
33
|
+
import { createCallSounds } from "./stage/callSounds";
|
|
34
|
+
import { CallCenterNotice } from "./stage/CallCenterNotice";
|
|
35
|
+
import { CallControlsBar } from "./stage/CallControlsBar";
|
|
36
|
+
import { CallToasts, type CallToast } from "./stage/CallToasts";
|
|
37
|
+
import { useMeasuredWidth, STAGE_NARROW_MAX_PX } from "./hooks/useMeasuredWidth";
|
|
38
|
+
import { CallHealthPanel } from "./stage/CallHealthPanel";
|
|
39
|
+
import { CallTopBar } from "./stage/CallTopBar";
|
|
40
|
+
import { ReactionOverlay } from "./stage/ReactionOverlay";
|
|
41
|
+
import { SelfView, type SelfViewCorner } from "./stage/SelfView";
|
|
42
|
+
import { stageTokens, type StageTokens } from "./stage/stageTheme";
|
|
43
|
+
import { VideoTile } from "./stage/VideoTile";
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
|
-
* The call, as a screen. One implementation,
|
|
44
|
-
*
|
|
45
|
-
* Everything above this file was reachable only from a test: the room, the
|
|
46
|
-
* hooks and `BookingCallProvider` all existed and nothing rendered a call, so
|
|
47
|
-
* no human could join one. This is the part that makes it reachable, and it is
|
|
48
|
-
* built ONCE here because there are two rendering surfaces (admin, and code
|
|
49
|
-
* websites through Forge) and a second implementation would mean every fix to
|
|
50
|
-
* the ordering rules had to be made twice. The second copy is always the one
|
|
51
|
-
* that drifts.
|
|
46
|
+
* The call, as a screen. One implementation, three rendering surfaces.
|
|
52
47
|
*
|
|
53
|
-
*
|
|
48
|
+
* This is the ORCHESTRATOR: the decisions live in the pure modules
|
|
49
|
+
* (`callState`, `stage/callLayout`, `stage/callSignals`) and the drawing in
|
|
50
|
+
* the components under `stage/` and `panels/`. What stays here is wiring the
|
|
51
|
+
* two together and the one `useReducer` of UI state.
|
|
54
52
|
*
|
|
55
|
-
*
|
|
56
|
-
* `themeSettings` fed through `<ForgeThemeProvider>`, and in admin is the
|
|
57
|
-
* dashboard's own tokens fed through the same provider. Not one colour is
|
|
58
|
-
* written down in this file. A call screen with a hard-coded palette is a call
|
|
59
|
-
* screen that looks like somebody else's product on every site that installs
|
|
60
|
-
* it.
|
|
53
|
+
* ## The stage is dark on purpose
|
|
61
54
|
*
|
|
62
|
-
*
|
|
55
|
+
* `stage/stageTheme.ts` is the ONE sanctioned exception to theme-only
|
|
56
|
+
* colors: near-black chrome from constants, every accent from the theme.
|
|
63
57
|
*
|
|
64
|
-
* `useForgeT()
|
|
65
|
-
* `forge.call_stage.*`. `callStatus` returns KEYS for the same reason it is
|
|
66
|
-
* pure: it has no locale. The one thing rendered verbatim is the server's own
|
|
67
|
-
* sentence (a refusal, a close reason), because a friendlier translation of a
|
|
68
|
-
* refusal is a lie about why you were refused. Admin renders this same
|
|
69
|
-
* component inside `<ForgeI18nProvider>` with the dashboard's locale; outside
|
|
70
|
-
* any provider the hook answers in English, so a site that has not mounted one
|
|
71
|
-
* still gets a working call.
|
|
58
|
+
* ## Words come from `useForgeT()`
|
|
72
59
|
*
|
|
73
|
-
*
|
|
60
|
+
* Keys under `forge.call_stage.*`. The one thing rendered verbatim is the
|
|
61
|
+
* server's own sentence (a refusal, a close reason).
|
|
74
62
|
*
|
|
75
|
-
*
|
|
76
|
-
* token for, and it is read here so a button that would be refused is not
|
|
77
|
-
* offered; the node checks the same thing on the publish itself and its answer
|
|
78
|
-
* is the only one that counts. Hiding a control is courtesy, never enforcement.
|
|
63
|
+
* ## Mount it behind PreJoin (or another Join control)
|
|
79
64
|
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* The provider connects on mount and closes on unmount. Rendering the whole
|
|
83
|
-
* thing inside a list of sessions would open a media connection for every row.
|
|
65
|
+
* The provider connects on mount and closes on unmount.
|
|
84
66
|
*/
|
|
85
67
|
|
|
68
|
+
export type CallStageRecording = {
|
|
69
|
+
isRecording: boolean;
|
|
70
|
+
canControl: boolean;
|
|
71
|
+
/** A request is in flight. Holds the button rather than letting it be pressed twice. */
|
|
72
|
+
pending?: boolean;
|
|
73
|
+
onStart: () => void;
|
|
74
|
+
onStop: () => void;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type CallStageMediaDefaults = {
|
|
78
|
+
audioDeviceId?: string;
|
|
79
|
+
videoDeviceId?: string;
|
|
80
|
+
joinWithMicOn?: boolean;
|
|
81
|
+
joinWithCameraOn?: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
86
84
|
export type CallStageProps = {
|
|
87
|
-
/**
|
|
88
|
-
* Called once the local participant has left. The host is expected to unmount
|
|
89
|
-
* the provider: this component closes the room, but a provider left mounted
|
|
90
|
-
* would reconnect on the next render.
|
|
91
|
-
*/
|
|
85
|
+
/** Called once the local participant has left. Unmount the provider. */
|
|
92
86
|
onLeave?: () => void;
|
|
93
|
-
/** Whose call this is. Rendered
|
|
87
|
+
/** Whose call this is. Rendered in the top bar. */
|
|
94
88
|
title?: ReactNode;
|
|
95
|
-
/** Ratio for
|
|
89
|
+
/** Ratio for gallery tiles. Defaults to 16 / 9. */
|
|
96
90
|
aspectRatio?: number;
|
|
91
|
+
/** The host's start/stop control, when the page offers one. */
|
|
92
|
+
recordingControl?: CallStageRecording;
|
|
93
|
+
/**
|
|
94
|
+
* The chat adapter, injected like `recordingControl`: the surfaces
|
|
95
|
+
* authenticate the platform socket differently and the stage must not know
|
|
96
|
+
* how. Absent means no chat tab or button is drawn.
|
|
97
|
+
*/
|
|
98
|
+
chat?: CallStageChat;
|
|
99
|
+
/** Rendering hint for host courtesies (lower-hand). The server enforces
|
|
100
|
+
* every real host action itself. */
|
|
101
|
+
isHost?: boolean;
|
|
102
|
+
/** From PreJoin: which devices to open, and whether to open them at all. */
|
|
103
|
+
mediaDefaults?: CallStageMediaDefaults;
|
|
104
|
+
/** Opt call cameras into simulcast (group calls). Wrong for a 1:1, where
|
|
105
|
+
* one encoding carries and extra layers are encoder time spent on nobody. */
|
|
106
|
+
cameraSimulcast?: boolean;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type StageUiState = {
|
|
110
|
+
view: ViewChoice;
|
|
111
|
+
page: number;
|
|
112
|
+
pinnedKey: string | null;
|
|
113
|
+
panel: SidePanelTab | null;
|
|
114
|
+
selfHidden: boolean;
|
|
115
|
+
selfCorner: SelfViewCorner;
|
|
116
|
+
lastSeenMessageId: string | null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type StageUiAction =
|
|
120
|
+
| { type: "view"; view: ViewChoice }
|
|
121
|
+
| { type: "page"; delta: number }
|
|
122
|
+
| { type: "pin"; key: string | null }
|
|
123
|
+
| { type: "panel"; panel: SidePanelTab | null }
|
|
124
|
+
| { type: "selfHidden"; hidden: boolean }
|
|
125
|
+
| { type: "selfCorner"; corner: SelfViewCorner }
|
|
126
|
+
| { type: "seen"; messageId: string | null };
|
|
127
|
+
|
|
128
|
+
const initialUi: StageUiState = {
|
|
129
|
+
view: "gallery",
|
|
130
|
+
page: 0,
|
|
131
|
+
pinnedKey: null,
|
|
132
|
+
panel: null,
|
|
133
|
+
selfHidden: false,
|
|
134
|
+
selfCorner: "br",
|
|
135
|
+
lastSeenMessageId: null,
|
|
97
136
|
};
|
|
98
137
|
|
|
99
|
-
|
|
100
|
-
|
|
138
|
+
function uiReducer(state: StageUiState, action: StageUiAction): StageUiState {
|
|
139
|
+
switch (action.type) {
|
|
140
|
+
case "view":
|
|
141
|
+
// Changing how you look resets where you were looking.
|
|
142
|
+
return { ...state, view: action.view, page: 0, pinnedKey: null };
|
|
143
|
+
case "page":
|
|
144
|
+
return { ...state, page: Math.max(0, state.page + action.delta) };
|
|
145
|
+
case "pin":
|
|
146
|
+
return { ...state, pinnedKey: action.key, page: 0 };
|
|
147
|
+
case "panel":
|
|
148
|
+
return { ...state, panel: action.panel };
|
|
149
|
+
case "selfHidden":
|
|
150
|
+
return { ...state, selfHidden: action.hidden };
|
|
151
|
+
case "selfCorner":
|
|
152
|
+
return { ...state, selfCorner: action.corner };
|
|
153
|
+
case "seen":
|
|
154
|
+
return { ...state, lastSeenMessageId: action.messageId };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function CallStage({
|
|
159
|
+
onLeave,
|
|
160
|
+
title,
|
|
161
|
+
aspectRatio = 16 / 9,
|
|
162
|
+
recordingControl,
|
|
163
|
+
chat,
|
|
164
|
+
isHost = false,
|
|
165
|
+
mediaDefaults,
|
|
166
|
+
cameraSimulcast = false,
|
|
167
|
+
}: CallStageProps) {
|
|
168
|
+
const themeTokens = useThemeTokens();
|
|
169
|
+
const tokens = useMemo(() => stageTokens(themeTokens), [themeTokens]);
|
|
101
170
|
const t = useForgeT();
|
|
102
171
|
const { connectionState, error, recovering, retry } = useMediaRoom();
|
|
103
172
|
const state = useRoomState();
|
|
104
173
|
const peers = useParticipants();
|
|
105
174
|
const activeSpeakers = useActiveSpeakers();
|
|
106
175
|
const recording = useRecording();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
*
|
|
110
|
-
* The reducer keeps every producer the node has ever announced so a subscribe
|
|
111
|
-
* rule that narrows and later widens is reversible, which means the raw list
|
|
112
|
-
* can hold people this seat has been barred from seeing. On a sealed room
|
|
113
|
-
* their presence is most of what the barrier was hiding, and a tile carries
|
|
114
|
-
* their identity.
|
|
115
|
-
*/
|
|
176
|
+
// The barrier, applied: NOT `state.producers`. The raw list can hold people
|
|
177
|
+
// this seat is barred from, and a tile carries their identity.
|
|
116
178
|
const producers = useVisibleProducers();
|
|
179
|
+
const publications = useLocalPublications();
|
|
180
|
+
const speakers = useSpeakers();
|
|
181
|
+
const { observeTile } = useTileViewport();
|
|
182
|
+
const hands = useRaisedHands();
|
|
183
|
+
const flying = useFlyingReactions();
|
|
184
|
+
const media = useLocalMedia();
|
|
185
|
+
|
|
186
|
+
const [ui, dispatch] = useReducer(uiReducer, initialUi);
|
|
117
187
|
|
|
118
188
|
const status = useMemo(
|
|
119
189
|
() => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
|
|
120
190
|
[connectionState, error, recovering, state.phase, state.closedReason],
|
|
121
191
|
);
|
|
122
192
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
193
|
+
// Everyone by name, INCLUDING self as "You" - the peer list is other people
|
|
194
|
+
// only, so without this a person's own raised hand (and their own tile in
|
|
195
|
+
// the health panel) read as a raw identity, while everyone ELSE saw their
|
|
196
|
+
// name. Matches the self-view's own "You" label.
|
|
197
|
+
const peerNames = useMemo(() => {
|
|
198
|
+
const map = new Map(peers.map((peer) => [peer.identity, peer.name ?? peer.identity]));
|
|
199
|
+
if (state.identity) map.set(state.identity, t("forge.call_stage.you"));
|
|
200
|
+
return map;
|
|
201
|
+
}, [peers, state.identity, t]);
|
|
126
202
|
const tiles = useMemo(
|
|
127
203
|
() => callTiles({ identity: state.identity, peers, producers, activeSpeakers }),
|
|
128
204
|
[state.identity, peers, producers, activeSpeakers],
|
|
@@ -132,560 +208,622 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
132
208
|
[state.identity, producers],
|
|
133
209
|
);
|
|
134
210
|
|
|
211
|
+
const remoteCount = useMemo(() => new Set(tiles.map((tile) => tile.identity)).size, [tiles]);
|
|
212
|
+
const everSawPeersRef = useRef(false);
|
|
213
|
+
if (remoteCount > 0) everSawPeersRef.current = true;
|
|
214
|
+
const notice = useMemo(
|
|
215
|
+
() => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current }),
|
|
216
|
+
[status, remoteCount],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Dominant speaker with a hold, so the rail and speaker view do not flap.
|
|
220
|
+
const [dominant, setDominant] = useState<{ identity: string | null; since: number }>({ identity: null, since: 0 });
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
setDominant((previous) =>
|
|
223
|
+
pickDominant({ speakers, previous: previous.identity, previousSince: previous.since, now: Date.now() }),
|
|
224
|
+
);
|
|
225
|
+
}, [speakers]);
|
|
226
|
+
|
|
227
|
+
const localPresenting = useMemo(() => publications.some((p) => p.source === "screen"), [publications]);
|
|
228
|
+
|
|
229
|
+
// The stage's OWN width, not the window's: it embeds inside pages and modals.
|
|
230
|
+
// Below the breakpoint the tiles stack as portraits and the side panel folds
|
|
231
|
+
// into a full-screen overlay, so nothing is crushed to a sliver on a phone.
|
|
232
|
+
const [stageRef, stageWidth] = useMeasuredWidth<HTMLDivElement>();
|
|
233
|
+
const narrow = stageWidth > 0 && stageWidth < STAGE_NARROW_MAX_PX;
|
|
234
|
+
|
|
235
|
+
const layout = useMemo(
|
|
236
|
+
() =>
|
|
237
|
+
resolveLayout({
|
|
238
|
+
tiles,
|
|
239
|
+
view: ui.view,
|
|
240
|
+
page: ui.page,
|
|
241
|
+
pinnedKey: ui.pinnedKey,
|
|
242
|
+
dominantIdentity: dominant.identity,
|
|
243
|
+
localPresenting,
|
|
244
|
+
narrow,
|
|
245
|
+
pageSize: GALLERY_PAGE_SIZE,
|
|
246
|
+
}),
|
|
247
|
+
[tiles, ui.view, ui.page, ui.pinnedKey, dominant.identity, localPresenting, narrow],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Chimes: unlocked by any control press, played on arrivals and departures.
|
|
251
|
+
const sounds = useMemo(() => createCallSounds(), []);
|
|
252
|
+
const unlockSounds = useCallback(() => sounds.unlock(), [sounds]);
|
|
253
|
+
|
|
254
|
+
// A hand going up rings for EVERYONE: each client watches the shared
|
|
255
|
+
// raised-hands state and chimes when it gains an identity. Seeded with the
|
|
256
|
+
// current set so the first render and a reconnect's snapshot do not fire a
|
|
257
|
+
// burst for hands that were already up.
|
|
258
|
+
const previousHandIdsRef = useRef(new Set(hands.hands.map((h) => h.identity)));
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
const now = new Set(hands.hands.map((h) => h.identity));
|
|
261
|
+
let raised = false;
|
|
262
|
+
for (const id of now) if (!previousHandIdsRef.current.has(id)) raised = true;
|
|
263
|
+
previousHandIdsRef.current = now;
|
|
264
|
+
if (raised) sounds.raiseHand();
|
|
265
|
+
}, [hands.hands, sounds]);
|
|
266
|
+
|
|
267
|
+
// Join/leave toasts from the peer snapshot, collapsed under bursts.
|
|
268
|
+
const [toasts, setToasts] = useState<readonly CallToast[]>([]);
|
|
269
|
+
const previousPeersRef = useRef(peers);
|
|
270
|
+
const toastCounter = useRef(0);
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const changes = diffPeers(previousPeersRef.current, peers);
|
|
273
|
+
previousPeersRef.current = peers;
|
|
274
|
+
if (changes.length === 0) return;
|
|
275
|
+
if (changes.some((c) => c.kind === "joined")) sounds.join();
|
|
276
|
+
if (changes.some((c) => c.kind === "left")) sounds.leave();
|
|
277
|
+
const texts =
|
|
278
|
+
changes.length > 3
|
|
279
|
+
? [t("forge.call_stage.many_joined", { count: changes.length })]
|
|
280
|
+
: changes.map((c) =>
|
|
281
|
+
c.kind === "joined"
|
|
282
|
+
? t("forge.call_stage.peer_joined", { name: c.name })
|
|
283
|
+
: t("forge.call_stage.peer_left", { name: c.name }),
|
|
284
|
+
);
|
|
285
|
+
const added: CallToast[] = texts.map((text) => ({ id: `toast-${toastCounter.current++}`, text }));
|
|
286
|
+
setToasts((current) => [...current, ...added].slice(-3));
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
setToasts((current) => current.filter((toast) => !added.some((a) => a.id === toast.id)));
|
|
289
|
+
}, 4000);
|
|
290
|
+
return () => clearTimeout(timer);
|
|
291
|
+
}, [peers, sounds, t]);
|
|
292
|
+
|
|
293
|
+
// The unread badge and its "seen" watermark: reading the panel reads it.
|
|
294
|
+
const chatMessageIds = useMemo(() => (chat ? chat.messages.map((m) => m.id) : []), [chat?.messages]);
|
|
295
|
+
const chatUnread = ui.panel === "chat" ? 0 : unreadCount(chatMessageIds, ui.lastSeenMessageId);
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (ui.panel !== "chat") return;
|
|
298
|
+
const last = chatMessageIds[chatMessageIds.length - 1] ?? null;
|
|
299
|
+
if (last !== ui.lastSeenMessageId) dispatch({ type: "seen", messageId: last });
|
|
300
|
+
}, [ui.panel, chatMessageIds, ui.lastSeenMessageId]);
|
|
301
|
+
|
|
302
|
+
// The timer starts at the FIRST successful connect and survives blips.
|
|
303
|
+
const [connectedAt, setConnectedAt] = useState<number | null>(null);
|
|
304
|
+
const [healthOpen, setHealthOpen] = useState(false);
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (connectionState === "connected") setConnectedAt((current) => current ?? Date.now());
|
|
307
|
+
}, [connectionState]);
|
|
308
|
+
|
|
309
|
+
const togglePanel = (tab: SidePanelTab) => dispatch({ type: "panel", panel: ui.panel === tab ? null : tab });
|
|
310
|
+
|
|
311
|
+
const showSelfView =
|
|
312
|
+
!ui.selfHidden && layout.mode !== "duo" && connectionState === "connected" && (!notice || notice.kind === "waiting" || notice.kind === "everyone_left");
|
|
313
|
+
|
|
135
314
|
return (
|
|
136
315
|
<div
|
|
316
|
+
ref={stageRef}
|
|
137
317
|
style={{
|
|
138
318
|
display: "flex",
|
|
139
319
|
flexDirection: "column",
|
|
140
|
-
gap: "0.
|
|
141
|
-
padding: "
|
|
320
|
+
gap: narrow ? "0.375rem" : "0.5rem",
|
|
321
|
+
padding: narrow ? "0.5rem" : "0.75rem",
|
|
142
322
|
borderRadius: tokens.cornerRadius,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
color: tokens.text,
|
|
323
|
+
backgroundColor: tokens.stageBg,
|
|
324
|
+
color: tokens.stageText,
|
|
146
325
|
fontFamily: tokens.fontFamily,
|
|
147
|
-
/**
|
|
148
|
-
* Fill the surface rather than growing to fit the tiles.
|
|
149
|
-
*
|
|
150
|
-
* This laid out as a content-sized panel, which was right when a call
|
|
151
|
-
* was a modal and wrong the moment it became a full-screen page. With
|
|
152
|
-
* ONE participant `auto-fit` collapses the grid to a single full-width
|
|
153
|
-
* column, and a 16/9 tile at full width is taller than the viewport, so
|
|
154
|
-
* the controls were pushed off the bottom of the screen. Two people
|
|
155
|
-
* halved the width and hid the bug, which is why it only appeared to
|
|
156
|
-
* somebody sitting in a call alone.
|
|
157
|
-
*
|
|
158
|
-
* `100%` degrades to `auto` inside a parent with no height, so the
|
|
159
|
-
* embedded use (`BookingCallScreen`) is unaffected.
|
|
160
|
-
*/
|
|
161
326
|
height: "100%",
|
|
162
327
|
minHeight: 0,
|
|
163
328
|
boxSizing: "border-box",
|
|
164
329
|
overflow: "hidden",
|
|
165
330
|
}}
|
|
166
331
|
>
|
|
332
|
+
<CallTopBar
|
|
333
|
+
title={title}
|
|
334
|
+
tokens={tokens}
|
|
335
|
+
t={t}
|
|
336
|
+
status={status}
|
|
337
|
+
recording={recording}
|
|
338
|
+
connectedAt={connectedAt}
|
|
339
|
+
view={ui.view}
|
|
340
|
+
onView={(view) => dispatch({ type: "view", view })}
|
|
341
|
+
headCount={callHeadCount(tiles)}
|
|
342
|
+
showViewToggle={layout.mode === "gallery" || layout.mode === "speaker"}
|
|
343
|
+
healthOpen={healthOpen}
|
|
344
|
+
onToggleHealth={() => setHealthOpen((open) => !open)}
|
|
345
|
+
raisedHands={hands.hands.map((h) => ({ identity: h.identity, name: peerNames.get(h.identity) ?? h.identity }))}
|
|
346
|
+
onOpenHands={() => togglePanel("people")}
|
|
347
|
+
compact={narrow}
|
|
348
|
+
/>
|
|
349
|
+
|
|
350
|
+
<div style={{ display: "flex", gap: "0.5rem", flex: 1, minHeight: 0 }}>
|
|
351
|
+
<div style={{ position: "relative", flex: 1, minWidth: 0, minHeight: 0, display: "flex" }}>
|
|
352
|
+
{healthOpen && (
|
|
353
|
+
<CallHealthPanel
|
|
354
|
+
tokens={tokens}
|
|
355
|
+
t={t}
|
|
356
|
+
names={peerNames}
|
|
357
|
+
onClose={() => setHealthOpen(false)}
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
360
|
+
<StageArea
|
|
361
|
+
layout={layout}
|
|
362
|
+
tokens={tokens}
|
|
363
|
+
t={t}
|
|
364
|
+
narrow={narrow}
|
|
365
|
+
aspectRatio={aspectRatio}
|
|
366
|
+
observeTile={observeTile}
|
|
367
|
+
hands={hands.hands}
|
|
368
|
+
pinnedKey={ui.pinnedKey}
|
|
369
|
+
onTogglePin={(key) => dispatch({ type: "pin", key: ui.pinnedKey === key ? null : key })}
|
|
370
|
+
onPage={(delta) => dispatch({ type: "page", delta })}
|
|
371
|
+
localPresenting={localPresenting}
|
|
372
|
+
onStopPresenting={() => void media.unpublish("screen")}
|
|
373
|
+
/>
|
|
374
|
+
|
|
375
|
+
{notice && <CallCenterNotice notice={notice} tokens={tokens} t={t} onRetry={retry} />}
|
|
376
|
+
<ReactionOverlay reactions={flying.reactions} />
|
|
377
|
+
<CallToasts toasts={toasts} tokens={tokens} t={t} />
|
|
378
|
+
{layout.offPageSpeakerNames.length > 0 && (
|
|
379
|
+
<span
|
|
380
|
+
data-testid="off-page-speakers"
|
|
381
|
+
style={{
|
|
382
|
+
position: "absolute",
|
|
383
|
+
bottom: "0.5rem",
|
|
384
|
+
left: "50%",
|
|
385
|
+
transform: "translateX(-50%)",
|
|
386
|
+
fontSize: "0.75rem",
|
|
387
|
+
padding: "0.2rem 0.65rem",
|
|
388
|
+
borderRadius: 9999,
|
|
389
|
+
backgroundColor: "rgba(0,0,0,0.65)",
|
|
390
|
+
color: tokens.stageText,
|
|
391
|
+
pointerEvents: "none",
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
{t("forge.call_stage.speaking_off_page", { names: layout.offPageSpeakerNames.join(", ") })}
|
|
395
|
+
</span>
|
|
396
|
+
)}
|
|
397
|
+
{showSelfView && (
|
|
398
|
+
<SelfView
|
|
399
|
+
tokens={tokens}
|
|
400
|
+
t={t}
|
|
401
|
+
corner={ui.selfCorner}
|
|
402
|
+
onCorner={(corner) => dispatch({ type: "selfCorner", corner })}
|
|
403
|
+
onHide={() => dispatch({ type: "selfHidden", hidden: true })}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
{/* On a phone the panel COVERS the stage instead of docking beside
|
|
408
|
+
it, so the video never shrinks to a sliver. Absolute inside the
|
|
409
|
+
relative stage container, above the tiles. */}
|
|
410
|
+
{ui.panel && narrow && (
|
|
411
|
+
<div data-testid="call-panel-overlay" style={{ position: "absolute", inset: 0, zIndex: 30, display: "flex" }}>
|
|
412
|
+
<CallSidePanel
|
|
413
|
+
tab={chat ? ui.panel : "people"}
|
|
414
|
+
onTab={(tab) => dispatch({ type: "panel", panel: tab })}
|
|
415
|
+
onClose={() => dispatch({ type: "panel", panel: null })}
|
|
416
|
+
tokens={tokens}
|
|
417
|
+
t={t}
|
|
418
|
+
{...(chat ? { chat } : {})}
|
|
419
|
+
tiles={tiles}
|
|
420
|
+
hands={hands.hands}
|
|
421
|
+
isHost={isHost}
|
|
422
|
+
myIdentity={state.identity}
|
|
423
|
+
onLowerHand={hands.lowerHandOf}
|
|
424
|
+
onLowerAll={hands.lowerAll}
|
|
425
|
+
unread={chatUnread}
|
|
426
|
+
fill
|
|
427
|
+
/>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
|
|
432
|
+
{ui.panel && !narrow && (
|
|
433
|
+
<CallSidePanel
|
|
434
|
+
tab={chat ? ui.panel : "people"}
|
|
435
|
+
onTab={(tab) => dispatch({ type: "panel", panel: tab })}
|
|
436
|
+
onClose={() => dispatch({ type: "panel", panel: null })}
|
|
437
|
+
tokens={tokens}
|
|
438
|
+
t={t}
|
|
439
|
+
{...(chat ? { chat } : {})}
|
|
440
|
+
tiles={tiles}
|
|
441
|
+
hands={hands.hands}
|
|
442
|
+
isHost={isHost}
|
|
443
|
+
myIdentity={state.identity}
|
|
444
|
+
onLowerHand={hands.lowerHandOf}
|
|
445
|
+
onLowerAll={hands.lowerAll}
|
|
446
|
+
unread={chatUnread}
|
|
447
|
+
/>
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
{audioProducerIds.map((producerId) => (
|
|
452
|
+
<RemoteAudio key={producerId} producerId={producerId} />
|
|
453
|
+
))}
|
|
454
|
+
|
|
455
|
+
<CallControlsBar
|
|
456
|
+
tokens={tokens}
|
|
457
|
+
connected={connectionState === "connected"}
|
|
458
|
+
onLeave={onLeave}
|
|
459
|
+
recordingControl={recordingControl}
|
|
460
|
+
cameraSimulcast={cameraSimulcast}
|
|
461
|
+
myHandRaised={hands.myHandRaised}
|
|
462
|
+
onRaiseHand={hands.raiseHand}
|
|
463
|
+
onLowerHand={hands.lowerHand}
|
|
464
|
+
onSendReaction={flying.sendReaction}
|
|
465
|
+
chatAvailable={!!chat}
|
|
466
|
+
chatUnread={chatUnread}
|
|
467
|
+
onToggleChat={() => togglePanel("chat")}
|
|
468
|
+
onTogglePeople={() => togglePanel("people")}
|
|
469
|
+
chatOpen={ui.panel === "chat"}
|
|
470
|
+
peopleOpen={ui.panel === "people"}
|
|
471
|
+
onGesture={unlockSounds}
|
|
472
|
+
/>
|
|
473
|
+
|
|
474
|
+
<AutoPublish connected={connectionState === "connected"} mediaDefaults={mediaDefaults} cameraSimulcast={cameraSimulcast} />
|
|
475
|
+
</div>
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Publish what PreJoin chose, once, when the room is first connected.
|
|
481
|
+
*
|
|
482
|
+
* Its own component so it can use the SAME `useLocalMedia` lock family as the
|
|
483
|
+
* control bar without adding effects to the orchestrator; the ref makes it
|
|
484
|
+
* once-per-mount, so a reconnect does not re-open a camera the person turned
|
|
485
|
+
* off mid-call (`lostSources` handles that conversation).
|
|
486
|
+
*/
|
|
487
|
+
function AutoPublish({
|
|
488
|
+
connected,
|
|
489
|
+
mediaDefaults,
|
|
490
|
+
cameraSimulcast,
|
|
491
|
+
}: {
|
|
492
|
+
connected: boolean;
|
|
493
|
+
mediaDefaults?: CallStageMediaDefaults | undefined;
|
|
494
|
+
cameraSimulcast?: boolean | undefined;
|
|
495
|
+
}) {
|
|
496
|
+
const media = useLocalMedia();
|
|
497
|
+
const done = useRef(false);
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (!connected || done.current || !mediaDefaults) return;
|
|
500
|
+
done.current = true;
|
|
501
|
+
if (mediaDefaults.joinWithMicOn) {
|
|
502
|
+
void media.publishMicrophone(mediaDefaults.audioDeviceId ? { deviceId: mediaDefaults.audioDeviceId } : undefined);
|
|
503
|
+
}
|
|
504
|
+
if (mediaDefaults.joinWithCameraOn) {
|
|
505
|
+
void media.publishCamera({
|
|
506
|
+
...(mediaDefaults.videoDeviceId ? { deviceId: mediaDefaults.videoDeviceId } : {}),
|
|
507
|
+
...(cameraSimulcast ? { simulcast: true } : {}),
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}, [connected, mediaDefaults, cameraSimulcast, media]);
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The tiles, arranged. Which arrangement is `resolveLayout`'s answer. */
|
|
515
|
+
function StageArea({
|
|
516
|
+
layout,
|
|
517
|
+
tokens,
|
|
518
|
+
t,
|
|
519
|
+
narrow,
|
|
520
|
+
aspectRatio,
|
|
521
|
+
observeTile,
|
|
522
|
+
hands,
|
|
523
|
+
pinnedKey,
|
|
524
|
+
onTogglePin,
|
|
525
|
+
onPage,
|
|
526
|
+
localPresenting,
|
|
527
|
+
onStopPresenting,
|
|
528
|
+
}: {
|
|
529
|
+
layout: StageLayout;
|
|
530
|
+
tokens: StageTokens;
|
|
531
|
+
t: ForgeT;
|
|
532
|
+
narrow: boolean;
|
|
533
|
+
aspectRatio: number;
|
|
534
|
+
observeTile: (producerId: string) => (element: HTMLElement | null) => void;
|
|
535
|
+
hands: readonly { identity: string; raisedAt: number }[];
|
|
536
|
+
pinnedKey: string | null;
|
|
537
|
+
onTogglePin: (key: string) => void;
|
|
538
|
+
onPage: (delta: number) => void;
|
|
539
|
+
localPresenting: boolean;
|
|
540
|
+
onStopPresenting: () => void;
|
|
541
|
+
}) {
|
|
542
|
+
const handAt = (identity: string) => hands.find((h) => h.identity === identity)?.raisedAt;
|
|
543
|
+
|
|
544
|
+
const pager =
|
|
545
|
+
layout.pageCount > 1 ? (
|
|
546
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", flexShrink: 0 }}>
|
|
547
|
+
<PageArrow direction={-1} disabled={layout.page === 0} onPage={onPage} tokens={tokens} t={t} />
|
|
548
|
+
<span style={{ fontSize: "0.75rem", color: tokens.stageMuted }}>
|
|
549
|
+
{t("forge.call_stage.page_of", { page: layout.page + 1, pages: layout.pageCount })}
|
|
550
|
+
</span>
|
|
551
|
+
<PageArrow direction={1} disabled={layout.page >= layout.pageCount - 1} onPage={onPage} tokens={tokens} t={t} />
|
|
552
|
+
</div>
|
|
553
|
+
) : null;
|
|
554
|
+
|
|
555
|
+
const tileFor = (tile: CallTile, fill: boolean) => (
|
|
556
|
+
<VideoTile
|
|
557
|
+
key={tile.key}
|
|
558
|
+
tile={tile}
|
|
559
|
+
tokens={tokens}
|
|
560
|
+
t={t}
|
|
561
|
+
observeTile={observeTile}
|
|
562
|
+
handRaisedAt={handAt(tile.identity)}
|
|
563
|
+
pinned={pinnedKey === tile.key}
|
|
564
|
+
onTogglePin={() => onTogglePin(tile.key)}
|
|
565
|
+
fill={fill}
|
|
566
|
+
aspectRatio={aspectRatio}
|
|
567
|
+
/>
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
if (layout.mode === "presenting") {
|
|
571
|
+
return (
|
|
167
572
|
<div
|
|
573
|
+
data-testid="presenting-stage"
|
|
168
574
|
style={{
|
|
169
575
|
display: "flex",
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
576
|
+
// On a phone the share sits on TOP at full width with the faces as a
|
|
577
|
+
// strip beneath it; a side rail would squeeze the share into a sliver
|
|
578
|
+
// on one edge. On a desktop the rail is beside it as before.
|
|
579
|
+
flexDirection: narrow ? "column" : "row",
|
|
580
|
+
gap: "0.5rem",
|
|
581
|
+
flex: 1,
|
|
582
|
+
minWidth: 0,
|
|
583
|
+
minHeight: 0,
|
|
174
584
|
}}
|
|
175
585
|
>
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
586
|
+
<div style={{ flex: 1, minWidth: 0, minHeight: 0, display: "flex", flexDirection: "column", gap: "0.375rem" }}>
|
|
587
|
+
{layout.main ? (
|
|
588
|
+
<>
|
|
589
|
+
<span data-testid="presenter-label" style={{ fontSize: "0.8125rem", color: tokens.stageMuted, flexShrink: 0 }}>
|
|
590
|
+
{t("forge.call_stage.presenting_name", { name: layout.presenter?.name ?? "" })}
|
|
591
|
+
</span>
|
|
592
|
+
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
|
593
|
+
{tileFor(layout.main, true)}
|
|
594
|
+
</div>
|
|
595
|
+
</>
|
|
596
|
+
) : (
|
|
597
|
+
// The viewer's OWN share: the browser does not hand a preview of
|
|
598
|
+
// it back cheaply, and mirroring your own screen recursively is
|
|
599
|
+
// the hall-of-mirrors everyone knows. A calm placard instead.
|
|
600
|
+
<div
|
|
601
|
+
data-testid="local-presenting"
|
|
602
|
+
style={{
|
|
603
|
+
flex: 1,
|
|
604
|
+
display: "flex",
|
|
605
|
+
flexDirection: "column",
|
|
606
|
+
alignItems: "center",
|
|
607
|
+
justifyContent: "center",
|
|
608
|
+
gap: "0.75rem",
|
|
609
|
+
borderRadius: tokens.cornerRadius,
|
|
610
|
+
border: `1px dashed ${tokens.stageBorder}`,
|
|
611
|
+
color: tokens.stageMuted,
|
|
612
|
+
}}
|
|
613
|
+
>
|
|
614
|
+
<span>{t("forge.call_stage.you_are_presenting")}</span>
|
|
615
|
+
<button
|
|
616
|
+
type="button"
|
|
617
|
+
onClick={onStopPresenting}
|
|
618
|
+
style={{
|
|
619
|
+
padding: "0.4rem 0.9rem",
|
|
620
|
+
borderRadius: tokens.cornerRadius,
|
|
621
|
+
border: `1px solid ${tokens.danger}`,
|
|
622
|
+
backgroundColor: "transparent",
|
|
623
|
+
color: tokens.stageText,
|
|
624
|
+
cursor: "pointer",
|
|
625
|
+
font: "inherit",
|
|
626
|
+
fontSize: "0.8125rem",
|
|
627
|
+
}}
|
|
628
|
+
>
|
|
629
|
+
{t("forge.call_stage.stop_presenting")}
|
|
630
|
+
</button>
|
|
631
|
+
</div>
|
|
632
|
+
)}
|
|
633
|
+
{localPresenting && layout.main && (
|
|
634
|
+
<span role="status" style={{ fontSize: "0.75rem", color: tokens.stageMuted, flexShrink: 0 }}>
|
|
635
|
+
{t("forge.call_stage.you_are_presenting")}
|
|
636
|
+
</span>
|
|
637
|
+
)}
|
|
180
638
|
</div>
|
|
639
|
+
{layout.pageTiles.length > 0 &&
|
|
640
|
+
(narrow ? (
|
|
641
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem", flexShrink: 0 }}>
|
|
642
|
+
<div style={{ display: "flex", gap: "0.375rem", height: "5rem", overflowX: "auto", overflowY: "hidden" }}>
|
|
643
|
+
{layout.pageTiles.map((tile) => (
|
|
644
|
+
<div key={tile.key} style={{ flexShrink: 0, aspectRatio: "16 / 9", height: "100%" }}>
|
|
645
|
+
{tileFor(tile, true)}
|
|
646
|
+
</div>
|
|
647
|
+
))}
|
|
648
|
+
</div>
|
|
649
|
+
{pager}
|
|
650
|
+
</div>
|
|
651
|
+
) : (
|
|
652
|
+
<div style={{ width: "clamp(8rem, 18%, 13rem)", display: "flex", flexDirection: "column", gap: "0.375rem", minHeight: 0 }}>
|
|
653
|
+
{layout.pageTiles.map((tile) => (
|
|
654
|
+
<div key={tile.key} style={{ flexShrink: 0 }}>
|
|
655
|
+
{tileFor(tile, false)}
|
|
656
|
+
</div>
|
|
657
|
+
))}
|
|
658
|
+
{pager}
|
|
659
|
+
</div>
|
|
660
|
+
))}
|
|
661
|
+
</div>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (layout.mode === "speaker" && layout.main) {
|
|
666
|
+
return (
|
|
667
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", flex: 1, minWidth: 0, minHeight: 0 }}>
|
|
668
|
+
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>{tileFor(layout.main, true)}</div>
|
|
669
|
+
{layout.pageTiles.length > 0 && (
|
|
670
|
+
<div style={{ display: "flex", gap: "0.375rem", justifyContent: "center", flexShrink: 0, height: "5.5rem" }}>
|
|
671
|
+
{layout.pageTiles.map((tile) => (
|
|
672
|
+
<div key={tile.key} style={{ height: "100%", aspectRatio: String(aspectRatio) }}>
|
|
673
|
+
{tileFor(tile, true)}
|
|
674
|
+
</div>
|
|
675
|
+
))}
|
|
676
|
+
{pager}
|
|
677
|
+
</div>
|
|
678
|
+
)}
|
|
181
679
|
</div>
|
|
680
|
+
);
|
|
681
|
+
}
|
|
182
682
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
683
|
+
if (layout.mode === "duo") {
|
|
684
|
+
return (
|
|
685
|
+
<div
|
|
686
|
+
style={{
|
|
687
|
+
display: "grid",
|
|
688
|
+
gridTemplateColumns: `repeat(${layout.columns}, 1fr)`,
|
|
689
|
+
// Explicit rows so the stacked phone case (one column, a row each)
|
|
690
|
+
// divides the height EVENLY into portraits, rather than letting the
|
|
691
|
+
// second tile size to content. Desktop (one row) is unchanged.
|
|
692
|
+
gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
|
|
693
|
+
gap: "0.5rem",
|
|
694
|
+
flex: 1,
|
|
695
|
+
minWidth: 0,
|
|
696
|
+
minHeight: 0,
|
|
697
|
+
}}
|
|
698
|
+
>
|
|
699
|
+
{layout.pageTiles.map((tile) => tileFor(tile, true))}
|
|
700
|
+
<LocalDuoTile tokens={tokens} t={t} />
|
|
188
701
|
</div>
|
|
702
|
+
);
|
|
703
|
+
}
|
|
189
704
|
|
|
705
|
+
return (
|
|
706
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", flex: 1, minWidth: 0, minHeight: 0 }}>
|
|
190
707
|
<div
|
|
191
708
|
style={{
|
|
192
709
|
display: "grid",
|
|
193
|
-
gridTemplateColumns:
|
|
194
|
-
gap: "0.
|
|
195
|
-
// The only part that may grow, and the only part that may scroll.
|
|
196
|
-
// `minHeight: 0` is what actually lets it shrink: a flex item defaults
|
|
197
|
-
// to `min-height: auto`, so without it the grid refuses to go below
|
|
198
|
-
// its content and pushes the controls out of the box instead.
|
|
710
|
+
gridTemplateColumns: `repeat(${layout.columns}, 1fr)`,
|
|
711
|
+
gap: "0.5rem",
|
|
199
712
|
flex: 1,
|
|
200
713
|
minHeight: 0,
|
|
201
|
-
overflowY: "auto",
|
|
202
|
-
/**
|
|
203
|
-
* Centred in the free space, not stacked at the top.
|
|
204
|
-
*
|
|
205
|
-
* `safe` is the load-bearing word: plain `center` centres the
|
|
206
|
-
* OVERFLOW too, so once there are more tiles than fit, the first row
|
|
207
|
-
* is pushed above the scroll origin and cannot be reached. `safe
|
|
208
|
-
* center` centres while there is room and falls back to start the
|
|
209
|
-
* moment there is not. An engine that does not understand it drops
|
|
210
|
-
* the declaration and starts at the top, which is the same fallback.
|
|
211
|
-
*/
|
|
212
714
|
alignContent: "safe center",
|
|
213
|
-
// Horizontally centred too. A tile clamped by `maxHeight` shrinks its
|
|
214
|
-
// width to keep 16/9, so it no longer fills its track and would
|
|
215
|
-
// otherwise sit against the left edge. With two or more tiles each
|
|
216
|
-
// fills its track and this changes nothing.
|
|
217
715
|
justifyItems: "center",
|
|
716
|
+
overflowY: "auto",
|
|
218
717
|
}}
|
|
219
718
|
>
|
|
220
|
-
|
|
221
|
-
{tiles.map((tile) => (
|
|
222
|
-
<ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} t={t} />
|
|
223
|
-
))}
|
|
719
|
+
{layout.pageTiles.map((tile) => tileFor(tile, false))}
|
|
224
720
|
</div>
|
|
225
|
-
|
|
226
|
-
{audioProducerIds.map((producerId) => (
|
|
227
|
-
<RemoteAudio key={producerId} producerId={producerId} />
|
|
228
|
-
))}
|
|
229
|
-
|
|
230
|
-
<CallControls tokens={tokens} t={t} connected={connectionState === "connected"} onLeave={onLeave} />
|
|
721
|
+
{pager}
|
|
231
722
|
</div>
|
|
232
723
|
);
|
|
233
724
|
}
|
|
234
725
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
* "reconnecting" and "we were refused" call for three different responses and a
|
|
240
|
-
* single spinner tells you which of them you are in exactly never.
|
|
241
|
-
*/
|
|
242
|
-
function ConnectionStatus({
|
|
243
|
-
status,
|
|
726
|
+
function PageArrow({
|
|
727
|
+
direction,
|
|
728
|
+
disabled,
|
|
729
|
+
onPage,
|
|
244
730
|
tokens,
|
|
245
731
|
t,
|
|
246
|
-
onRetry,
|
|
247
732
|
}: {
|
|
248
|
-
|
|
249
|
-
|
|
733
|
+
direction: -1 | 1;
|
|
734
|
+
disabled: boolean;
|
|
735
|
+
onPage: (delta: number) => void;
|
|
736
|
+
tokens: StageTokens;
|
|
250
737
|
t: ForgeT;
|
|
251
|
-
onRetry: () => void;
|
|
252
738
|
}) {
|
|
253
|
-
const busy = status.tone === "connecting" || status.tone === "reconnecting";
|
|
254
|
-
const alarming = status.tone === "failed";
|
|
255
|
-
// Our own copy is translated; the server's sentence is shown as it came.
|
|
256
|
-
const detail = status.detailKey ? t(status.detailKey) : status.detailText;
|
|
257
|
-
|
|
258
739
|
return (
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
gap: "0.5rem",
|
|
266
|
-
fontSize: "0.8125rem",
|
|
267
|
-
color: alarming ? tokens.text : tokens.muted,
|
|
268
|
-
}}
|
|
269
|
-
>
|
|
270
|
-
{busy && <Loader2 width={14} height={14} aria-hidden="true" />}
|
|
271
|
-
{alarming && <AlertTriangle width={14} height={14} aria-hidden="true" color={tokens.secondary} />}
|
|
272
|
-
{status.tone === "live" && (
|
|
273
|
-
<span
|
|
274
|
-
aria-hidden="true"
|
|
275
|
-
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
|
|
276
|
-
/>
|
|
277
|
-
)}
|
|
278
|
-
<span style={{ fontWeight: alarming ? 600 : 400 }}>{t(status.headlineKey)}</span>
|
|
279
|
-
{detail && <span style={{ color: tokens.muted }}>{detail}</span>}
|
|
280
|
-
{status.canRetry && (
|
|
281
|
-
<button
|
|
282
|
-
type="button"
|
|
283
|
-
onClick={onRetry}
|
|
284
|
-
style={{
|
|
285
|
-
display: "inline-flex",
|
|
286
|
-
alignItems: "center",
|
|
287
|
-
gap: "0.25rem",
|
|
288
|
-
border: `1px solid ${tokens.border}`,
|
|
289
|
-
borderRadius: tokens.cornerRadius,
|
|
290
|
-
backgroundColor: "transparent",
|
|
291
|
-
color: tokens.text,
|
|
292
|
-
padding: "0.125rem 0.5rem",
|
|
293
|
-
cursor: "pointer",
|
|
294
|
-
font: "inherit",
|
|
295
|
-
fontSize: "0.75rem",
|
|
296
|
-
}}
|
|
297
|
-
>
|
|
298
|
-
<RefreshCw width={12} height={12} aria-hidden="true" />
|
|
299
|
-
{t("forge.call_stage.retry")}
|
|
300
|
-
</button>
|
|
301
|
-
)}
|
|
302
|
-
</div>
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Somebody is recording. A consent signal, so it is content and always shown. */
|
|
307
|
-
function RecordingIndicator({ tokens, t }: { tokens: ResolvedThemeTokens; t: ForgeT }) {
|
|
308
|
-
return (
|
|
309
|
-
<span
|
|
740
|
+
<button
|
|
741
|
+
type="button"
|
|
742
|
+
data-testid={direction === 1 ? "page-next" : "page-prev"}
|
|
743
|
+
onClick={() => onPage(direction)}
|
|
744
|
+
disabled={disabled}
|
|
745
|
+
aria-label={direction === 1 ? t("forge.call_stage.page_next") : t("forge.call_stage.page_prev")}
|
|
310
746
|
style={{
|
|
311
747
|
display: "inline-flex",
|
|
312
748
|
alignItems: "center",
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
border: `1px solid ${tokens.
|
|
318
|
-
|
|
319
|
-
|
|
749
|
+
justifyContent: "center",
|
|
750
|
+
width: 28,
|
|
751
|
+
height: 28,
|
|
752
|
+
borderRadius: 9999,
|
|
753
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
754
|
+
backgroundColor: "transparent",
|
|
755
|
+
color: disabled ? tokens.stageMuted : tokens.stageText,
|
|
756
|
+
cursor: disabled ? "default" : "pointer",
|
|
757
|
+
opacity: disabled ? 0.5 : 1,
|
|
320
758
|
}}
|
|
321
759
|
>
|
|
322
|
-
<
|
|
323
|
-
|
|
324
|
-
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
|
|
325
|
-
/>
|
|
326
|
-
{t("forge.call_stage.recording")}
|
|
327
|
-
</span>
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: boolean): CSSProperties => ({
|
|
332
|
-
position: "relative",
|
|
333
|
-
overflow: "hidden",
|
|
334
|
-
aspectRatio: String(aspectRatio),
|
|
335
|
-
// A lone tile spans the whole grid, and 16/9 of a wide surface is taller than
|
|
336
|
-
// the surface. Clamping crops rather than distorts, because the video inside
|
|
337
|
-
// is `object-fit: cover`.
|
|
338
|
-
maxHeight: "100%",
|
|
339
|
-
borderRadius: tokens.cornerRadius,
|
|
340
|
-
backgroundColor: tokens.surface,
|
|
341
|
-
// The active speaker is shown by the FRAME rather than by re-ordering the
|
|
342
|
-
// grid: a grid that reshuffles itself every time somebody says "mm" is
|
|
343
|
-
// unusable, and the person you were looking at moves out from under the eye.
|
|
344
|
-
border: `2px solid ${speaking ? tokens.primary : tokens.border}`,
|
|
345
|
-
display: "flex",
|
|
346
|
-
alignItems: "center",
|
|
347
|
-
justifyContent: "center",
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
const tileName = (tokens: ResolvedThemeTokens): CSSProperties => ({
|
|
351
|
-
position: "absolute",
|
|
352
|
-
left: "0.5rem",
|
|
353
|
-
bottom: "0.5rem",
|
|
354
|
-
maxWidth: "calc(100% - 1rem)",
|
|
355
|
-
overflow: "hidden",
|
|
356
|
-
textOverflow: "ellipsis",
|
|
357
|
-
whiteSpace: "nowrap",
|
|
358
|
-
fontSize: "0.75rem",
|
|
359
|
-
padding: "0.125rem 0.375rem",
|
|
360
|
-
borderRadius: tokens.cornerRadius,
|
|
361
|
-
backgroundColor: tokens.background,
|
|
362
|
-
color: tokens.text,
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* One participant.
|
|
367
|
-
*
|
|
368
|
-
* A tile with no video renders the NAME, at size, and keeps its place in the
|
|
369
|
-
* grid. That is the whole point: somebody with the camera off has not left, and
|
|
370
|
-
* a grid that drops them re-flows under everyone else's eyes the moment one
|
|
371
|
-
* person turns a camera off.
|
|
372
|
-
*/
|
|
373
|
-
function ParticipantTile({
|
|
374
|
-
tile,
|
|
375
|
-
tokens,
|
|
376
|
-
aspectRatio,
|
|
377
|
-
t,
|
|
378
|
-
}: {
|
|
379
|
-
tile: CallTile;
|
|
380
|
-
tokens: ResolvedThemeTokens;
|
|
381
|
-
aspectRatio: number;
|
|
382
|
-
t: ForgeT;
|
|
383
|
-
}) {
|
|
384
|
-
// `useRemoteTrack` takes a producer id unconditionally so the hook order is
|
|
385
|
-
// the same for a tile with video and a tile without. An id that matches
|
|
386
|
-
// nothing simply returns no track, which is the state we want anyway.
|
|
387
|
-
const { track, attach } = useRemoteTrack(tile.videoProducerId ?? "");
|
|
388
|
-
const showVideo = !!tile.videoProducerId && !!track && !tile.videoPaused;
|
|
389
|
-
|
|
390
|
-
return (
|
|
391
|
-
<div
|
|
392
|
-
style={tileFrame(tokens, aspectRatio, tile.isSpeaking)}
|
|
393
|
-
data-testid={`tile-${tile.identity}`}
|
|
394
|
-
// The active set as an attribute as well as a border: a site restyling
|
|
395
|
-
// the grid needs the fact, and a colour is not something a test can
|
|
396
|
-
// assert without pinning a palette this component deliberately has none of.
|
|
397
|
-
data-speaking={tile.isSpeaking ? "true" : "false"}
|
|
398
|
-
>
|
|
399
|
-
{showVideo ? (
|
|
400
|
-
<video ref={attach} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
401
|
-
) : (
|
|
402
|
-
<span
|
|
403
|
-
style={{
|
|
404
|
-
fontSize: "0.9375rem",
|
|
405
|
-
fontWeight: 600,
|
|
406
|
-
color: tokens.text,
|
|
407
|
-
padding: "0 0.75rem",
|
|
408
|
-
textAlign: "center",
|
|
409
|
-
}}
|
|
410
|
-
>
|
|
411
|
-
{tile.name}
|
|
412
|
-
</span>
|
|
413
|
-
)}
|
|
414
|
-
<span style={tileName(tokens)}>
|
|
415
|
-
{tile.name}
|
|
416
|
-
{!tile.hasAudio && <span style={{ color: tokens.muted }}> {t("forge.call_stage.listening")}</span>}
|
|
417
|
-
</span>
|
|
418
|
-
</div>
|
|
760
|
+
{direction === 1 ? <ChevronRight width={14} height={14} aria-hidden="true" /> : <ChevronLeft width={14} height={14} aria-hidden="true" />}
|
|
761
|
+
</button>
|
|
419
762
|
);
|
|
420
763
|
}
|
|
421
764
|
|
|
422
|
-
/**
|
|
423
|
-
|
|
424
|
-
*
|
|
425
|
-
* Audio has to live on an element or the call is silent. It is deliberately NOT
|
|
426
|
-
* attached to the participant's `<video>`: a tile whose camera is off has no
|
|
427
|
-
* video element, and hanging the audio off one would mute whoever turned their
|
|
428
|
-
* camera off.
|
|
429
|
-
*/
|
|
430
|
-
function RemoteAudio({ producerId }: { producerId: string }) {
|
|
431
|
-
const { attach } = useRemoteTrack(producerId);
|
|
432
|
-
return <audio ref={attach} autoPlay style={{ display: "none" }} />;
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/** This viewer, as they are being sent. Muted, or it is a feedback loop. */
|
|
436
|
-
function LocalPreview({ tokens, aspectRatio, t }: { tokens: ResolvedThemeTokens; aspectRatio: number; t: ForgeT }) {
|
|
765
|
+
/** The 1:1 layout's second portrait: the viewer, at size, not a floating chip. */
|
|
766
|
+
function LocalDuoTile({ tokens, t }: { tokens: StageTokens; t: ForgeT }) {
|
|
437
767
|
const state = useRoomState();
|
|
438
768
|
const publications = useLocalPublications();
|
|
439
769
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
440
|
-
|
|
441
770
|
const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
|
|
442
|
-
// A paused camera is still captured and still a publication, but nothing is
|
|
443
|
-
// being sent, so the preview shows the name the others are seeing.
|
|
444
771
|
const track = camera && !camera.paused ? camera.track : undefined;
|
|
445
772
|
|
|
446
773
|
useEffect(() => {
|
|
447
774
|
const element = videoRef.current;
|
|
448
775
|
if (!element) return;
|
|
449
|
-
// Rebuilt only when the TRACK changes. Doing this in a ref callback would
|
|
450
|
-
// build a new `MediaStream` on every render, which is the per-frame churn
|
|
451
|
-
// this component exists to avoid.
|
|
452
776
|
element.srcObject = track ? new MediaStream([track]) : null;
|
|
453
777
|
return () => {
|
|
454
778
|
element.srcObject = null;
|
|
455
779
|
};
|
|
456
780
|
}, [track]);
|
|
457
781
|
|
|
458
|
-
const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.
|
|
459
|
-
|
|
460
|
-
return (
|
|
461
|
-
<div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
|
|
462
|
-
{track ? (
|
|
463
|
-
<video
|
|
464
|
-
ref={videoRef}
|
|
465
|
-
autoPlay
|
|
466
|
-
playsInline
|
|
467
|
-
muted
|
|
468
|
-
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
469
|
-
/>
|
|
470
|
-
) : (
|
|
471
|
-
<span style={{ fontSize: "0.9375rem", fontWeight: 600, color: tokens.text }}>{name}</span>
|
|
472
|
-
)}
|
|
473
|
-
<span style={tileName(tokens)}>{name}</span>
|
|
474
|
-
</div>
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/** One sentence per source a drop took away, keyed by the SDK's word for it. */
|
|
479
|
-
const LOST_SOURCE_KEYS: Record<LocalSource, string> = {
|
|
480
|
-
microphone: "forge.call_stage.lost_microphone",
|
|
481
|
-
camera: "forge.call_stage.lost_camera",
|
|
482
|
-
screen: "forge.call_stage.lost_screen",
|
|
483
|
-
};
|
|
484
|
-
|
|
485
|
-
const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
|
|
486
|
-
display: "inline-flex",
|
|
487
|
-
alignItems: "center",
|
|
488
|
-
gap: "0.375rem",
|
|
489
|
-
padding: "0.5rem 0.75rem",
|
|
490
|
-
borderRadius: tokens.cornerRadius,
|
|
491
|
-
border: `1px solid ${active ? tokens.primary : tokens.border}`,
|
|
492
|
-
backgroundColor: active ? tokens.primary : "transparent",
|
|
493
|
-
color: active ? tokens.textPrimary : tokens.text,
|
|
494
|
-
cursor: busy ? "progress" : "pointer",
|
|
495
|
-
opacity: busy ? 0.6 : 1,
|
|
496
|
-
font: "inherit",
|
|
497
|
-
fontSize: "0.8125rem",
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Microphone, camera, screen and leave.
|
|
502
|
-
*
|
|
503
|
-
* Each toggle is drawn only when the token was minted with the grant for it, so
|
|
504
|
-
* a listen-only seat is not offered a microphone it would be refused. That is a
|
|
505
|
-
* rendering decision; the node refuses the publish itself either way.
|
|
506
|
-
*
|
|
507
|
-
* ## Mute is a pause, not a republish
|
|
508
|
-
*
|
|
509
|
-
* The first press on Unmute or Start camera captures and publishes. Every press
|
|
510
|
-
* after that PAUSES or RESUMES the publication the room already holds, through
|
|
511
|
-
* `setPaused`. A toggle built as unpublish-then-publish starts every unmute
|
|
512
|
-
* with `getUserMedia`, and Safari puts a permission prompt in front of every
|
|
513
|
-
* call to it, so a person on an iPad was asked whether the site may use the
|
|
514
|
-
* microphone each time they spoke. Pausing keeps the capture open (the
|
|
515
|
-
* browser's device indicator stays on, which the camera hint says) and the way
|
|
516
|
-
* back asks nobody anything. Screen share is the exception: stopping it ends
|
|
517
|
-
* the capture, because sharing again has to go through the picker anyway.
|
|
518
|
-
*
|
|
519
|
-
* ## Why every toggle is disabled while it is working
|
|
520
|
-
*
|
|
521
|
-
* Each of these is a read-then-write: it reads whether the source is live and
|
|
522
|
-
* publishes, pauses or resumes accordingly. The flag it reads does not move
|
|
523
|
-
* until the publish has resolved, and a publish starts with a permission
|
|
524
|
-
* prompt that can sit on screen for seconds - so a button that looks untouched
|
|
525
|
-
* is a button a person presses again, and two presses used to mean two
|
|
526
|
-
* producers from one identity and everyone else hearing them doubled.
|
|
527
|
-
* `useLocalMedia` holds the real lock (a synchronous ref, since both clicks
|
|
528
|
-
* land before React re-renders anything); this is the part that says so on
|
|
529
|
-
* screen, because a control that silently ignores you is only marginally
|
|
530
|
-
* better than one that misbehaves.
|
|
531
|
-
*/
|
|
532
|
-
function CallControls({
|
|
533
|
-
tokens,
|
|
534
|
-
t,
|
|
535
|
-
connected,
|
|
536
|
-
onLeave,
|
|
537
|
-
}: {
|
|
538
|
-
tokens: ResolvedThemeTokens;
|
|
539
|
-
t: ForgeT;
|
|
540
|
-
connected: boolean;
|
|
541
|
-
onLeave?: () => void;
|
|
542
|
-
}) {
|
|
543
|
-
const grants = useGrants();
|
|
544
|
-
const { room } = useMediaRoom();
|
|
545
|
-
const media = useLocalMedia();
|
|
546
|
-
|
|
547
|
-
// What the person is actually SENDING, which is what the button says.
|
|
548
|
-
const microphoneLive = media.isMicrophoneEnabled && !media.paused.microphone;
|
|
549
|
-
const cameraLive = media.isCameraEnabled && !media.paused.camera;
|
|
550
|
-
|
|
551
|
-
// Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
|
|
552
|
-
// fresh object every render, so memoising on it would rebuild the callback
|
|
553
|
-
// every render anyway while looking as though it did not.
|
|
554
|
-
const toggleMicrophone = () => {
|
|
555
|
-
if (!media.isMicrophoneEnabled) {
|
|
556
|
-
void media.publishMicrophone();
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
void media.setPaused("microphone", microphoneLive);
|
|
560
|
-
};
|
|
561
|
-
const toggleCamera = () => {
|
|
562
|
-
if (!media.isCameraEnabled) {
|
|
563
|
-
void media.publishCamera();
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
void media.setPaused("camera", cameraLive);
|
|
567
|
-
};
|
|
568
|
-
const toggleScreen = () => {
|
|
569
|
-
void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
|
|
570
|
-
};
|
|
571
|
-
|
|
572
|
-
const leave = useCallback(() => {
|
|
573
|
-
// Closed here AND unmounted by the host. Closing alone would leave a
|
|
574
|
-
// provider mounted that reconnects on its next render; unmounting alone
|
|
575
|
-
// relies on a cleanup running before the person walks away from the laptop.
|
|
576
|
-
void room?.close().catch(() => undefined);
|
|
577
|
-
onLeave?.();
|
|
578
|
-
}, [room, onLeave]);
|
|
579
|
-
|
|
782
|
+
const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_joining");
|
|
580
783
|
return (
|
|
581
784
|
<div
|
|
785
|
+
data-testid="tile-local"
|
|
582
786
|
style={{
|
|
787
|
+
position: "relative",
|
|
788
|
+
overflow: "hidden",
|
|
789
|
+
borderRadius: tokens.cornerRadius,
|
|
790
|
+
backgroundColor: tokens.stageSurface,
|
|
791
|
+
border: `2px solid ${tokens.stageBorder}`,
|
|
583
792
|
display: "flex",
|
|
584
|
-
flexWrap: "wrap",
|
|
585
793
|
alignItems: "center",
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
// free space with `flex: 1`, so this holds even if that ever changes:
|
|
589
|
-
// the controls are the one thing that must never move off screen,
|
|
590
|
-
// because Leave is here.
|
|
591
|
-
marginTop: "auto",
|
|
592
|
-
flexShrink: 0,
|
|
794
|
+
justifyContent: "center",
|
|
795
|
+
minHeight: 0,
|
|
593
796
|
}}
|
|
594
797
|
>
|
|
595
|
-
{
|
|
596
|
-
<
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
disabled={media.pending.microphone}
|
|
600
|
-
aria-busy={media.pending.microphone}
|
|
601
|
-
style={controlButton(tokens, microphoneLive, media.pending.microphone)}
|
|
602
|
-
>
|
|
603
|
-
{microphoneLive ? (
|
|
604
|
-
<Mic width={16} height={16} aria-hidden="true" />
|
|
605
|
-
) : (
|
|
606
|
-
<MicOff width={16} height={16} aria-hidden="true" />
|
|
607
|
-
)}
|
|
608
|
-
{microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
|
|
609
|
-
</button>
|
|
610
|
-
)}
|
|
611
|
-
|
|
612
|
-
{canPublishSource(grants, "camera") && (
|
|
613
|
-
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
614
|
-
<button
|
|
615
|
-
type="button"
|
|
616
|
-
onClick={toggleCamera}
|
|
617
|
-
disabled={media.pending.camera}
|
|
618
|
-
aria-busy={media.pending.camera}
|
|
619
|
-
style={controlButton(tokens, cameraLive, media.pending.camera)}
|
|
620
|
-
>
|
|
621
|
-
{cameraLive ? (
|
|
622
|
-
<Video width={16} height={16} aria-hidden="true" />
|
|
623
|
-
) : (
|
|
624
|
-
<VideoOff width={16} height={16} aria-hidden="true" />
|
|
625
|
-
)}
|
|
626
|
-
{cameraLive ? t("forge.call_stage.camera_stop") : t("forge.call_stage.camera_start")}
|
|
627
|
-
</button>
|
|
628
|
-
<CallHelpHint label={t("forge.call_stage.help_camera_stop_label")}>
|
|
629
|
-
{t("forge.call_stage.help_camera_stop")}
|
|
630
|
-
</CallHelpHint>
|
|
631
|
-
</span>
|
|
632
|
-
)}
|
|
633
|
-
|
|
634
|
-
{canPublishSource(grants, "screen") && (
|
|
635
|
-
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}>
|
|
636
|
-
<button
|
|
637
|
-
type="button"
|
|
638
|
-
onClick={toggleScreen}
|
|
639
|
-
disabled={media.pending.screen}
|
|
640
|
-
aria-busy={media.pending.screen}
|
|
641
|
-
style={controlButton(tokens, media.screenSharing, media.pending.screen)}
|
|
642
|
-
>
|
|
643
|
-
{media.screenSharing ? (
|
|
644
|
-
<MonitorX width={16} height={16} aria-hidden="true" />
|
|
645
|
-
) : (
|
|
646
|
-
<MonitorUp width={16} height={16} aria-hidden="true" />
|
|
647
|
-
)}
|
|
648
|
-
{media.screenSharing ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
|
|
649
|
-
</button>
|
|
650
|
-
<CallHelpHint label={t("forge.call_stage.help_screen_label")}>
|
|
651
|
-
{t("forge.call_stage.help_screen")}
|
|
652
|
-
</CallHelpHint>
|
|
653
|
-
</span>
|
|
798
|
+
{track ? (
|
|
799
|
+
<video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
800
|
+
) : (
|
|
801
|
+
<span style={{ fontSize: "0.9375rem", fontWeight: 600 }}>{name}</span>
|
|
654
802
|
)}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
803
|
+
<span
|
|
804
|
+
style={{
|
|
805
|
+
position: "absolute",
|
|
806
|
+
left: "0.5rem",
|
|
807
|
+
bottom: "0.5rem",
|
|
808
|
+
fontSize: "0.75rem",
|
|
809
|
+
padding: "0.125rem 0.45rem",
|
|
810
|
+
borderRadius: tokens.cornerRadius,
|
|
811
|
+
backgroundColor: "rgba(0,0,0,0.55)",
|
|
812
|
+
color: tokens.stageText,
|
|
813
|
+
}}
|
|
660
814
|
>
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
</button>
|
|
664
|
-
|
|
665
|
-
{/* A denied permission prompt is something the person has to be told
|
|
666
|
-
about without asking, so it stays visible rather than going behind
|
|
667
|
-
the `?`. */}
|
|
668
|
-
{media.error && (
|
|
669
|
-
<span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
|
|
670
|
-
{media.error.message}
|
|
671
|
-
</span>
|
|
672
|
-
)}
|
|
673
|
-
|
|
674
|
-
{/* State the person has to act on, so it is content and stays visible: a
|
|
675
|
-
drop stopped their capture (the light went out, on purpose), the room
|
|
676
|
-
is back, and nothing will turn the microphone on again but them. Shown
|
|
677
|
-
once the room is back rather than during the drop, when the status
|
|
678
|
-
line above is already saying "reconnecting". An alert rather than a
|
|
679
|
-
status: somebody who misses it is talking to nobody. */}
|
|
680
|
-
{connected && media.lostSources.length > 0 && (
|
|
681
|
-
<span role="alert" style={{ width: "100%", fontSize: "0.8125rem", color: tokens.text }}>
|
|
682
|
-
{media.lostSources.map((source) => (
|
|
683
|
-
<span key={source} style={{ display: "block" }}>
|
|
684
|
-
{t(LOST_SOURCE_KEYS[source])}
|
|
685
|
-
</span>
|
|
686
|
-
))}
|
|
687
|
-
</span>
|
|
688
|
-
)}
|
|
815
|
+
{name}
|
|
816
|
+
</span>
|
|
689
817
|
</div>
|
|
690
818
|
);
|
|
691
819
|
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Remote audio, attached and never drawn. Deliberately NOT on the tile's
|
|
823
|
+
* `<video>`: a camera-off tile has no video element, and hanging audio off
|
|
824
|
+
* one would mute whoever turned their camera off.
|
|
825
|
+
*/
|
|
826
|
+
function RemoteAudio({ producerId }: { producerId: string }) {
|
|
827
|
+
const { attach } = useRemoteTrack(producerId);
|
|
828
|
+
return <audio ref={attach} autoPlay style={{ display: "none" }} />;
|
|
829
|
+
}
|