@tribe-nest/forge 3.31.0 → 3.35.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 +9 -3
- package/src/client/_tests/tenantHeaders.spec.ts +77 -0
- package/src/client/createForgeClient.ts +37 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/data/queries/useFilmPlaybackSession.ts +224 -0
- package/src/data/queries/useFilms.ts +561 -0
- package/src/data/queries/useMyBookings.ts +9 -0
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +140 -2
- package/src/i18n/en.json +140 -2
- package/src/index.ts +5 -0
- package/src/provider/ForgeAppProvider.tsx +10 -0
- package/src/provider/ForgeProvider.tsx +18 -3
- package/src/server/index.ts +76 -17
- package/src/ui/headless/film/FilmWatermark.tsx +180 -0
- package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
- package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
- package/src/ui/headless/film/index.ts +36 -0
- package/src/ui/headless/film/useFilmCatalog.ts +66 -0
- package/src/ui/headless/film/useFilmPlayback.ts +497 -0
- package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
- package/src/ui/headless/film/useStageFullscreen.ts +156 -0
- package/src/ui/headless/index.ts +6 -9
- package/src/ui/index.ts +43 -16
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallStage.tsx +212 -63
- package/src/ui/media/CallWindowNotice.tsx +94 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +265 -19
- package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +48 -0
- package/src/ui/media/_tests/callState.spec.ts +63 -16
- package/src/ui/media/bookingSession.tsx +45 -57
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +42 -23
- package/src/ui/media/index.ts +9 -0
- package/src/ui/styled/AccountDashboard.tsx +97 -5
- package/src/ui/styled/FilmCatalog.tsx +278 -0
- package/src/ui/styled/FilmDetail.tsx +661 -0
- package/src/ui/styled/FilmLibrary.tsx +254 -0
- package/src/ui/styled/FilmWatch.tsx +701 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +194 -0
- package/src/ui/styled/forge-utilities.css +835 -0
- package/src/utils/structuredData.ts +88 -17
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
useRemoteTrack,
|
|
24
24
|
useRoomState,
|
|
25
25
|
useVisibleProducers,
|
|
26
|
+
type LocalSource,
|
|
26
27
|
} from "@tribe-nest/media-client/react";
|
|
27
28
|
|
|
29
|
+
import { useForgeT, type ForgeT } from "../../i18n";
|
|
28
30
|
import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
|
|
29
31
|
import { CallHelpHint } from "./CallHelpHint";
|
|
30
32
|
import {
|
|
@@ -57,6 +59,17 @@ import {
|
|
|
57
59
|
* screen that looks like somebody else's product on every site that installs
|
|
58
60
|
* it.
|
|
59
61
|
*
|
|
62
|
+
* ## Where the words come from
|
|
63
|
+
*
|
|
64
|
+
* `useForgeT()`, like every other Forge component, with keys under
|
|
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.
|
|
72
|
+
*
|
|
60
73
|
* ## What it does NOT decide
|
|
61
74
|
*
|
|
62
75
|
* Whether a control may be used. `useGrants()` says what the node minted this
|
|
@@ -85,6 +98,7 @@ export type CallStageProps = {
|
|
|
85
98
|
|
|
86
99
|
export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStageProps) {
|
|
87
100
|
const tokens = useThemeTokens();
|
|
101
|
+
const t = useForgeT();
|
|
88
102
|
const { connectionState, error, recovering, retry } = useMediaRoom();
|
|
89
103
|
const state = useRoomState();
|
|
90
104
|
const peers = useParticipants();
|
|
@@ -130,23 +144,46 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
130
144
|
backgroundColor: tokens.background,
|
|
131
145
|
color: tokens.text,
|
|
132
146
|
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
|
+
height: "100%",
|
|
162
|
+
minHeight: 0,
|
|
163
|
+
boxSizing: "border-box",
|
|
164
|
+
overflow: "hidden",
|
|
133
165
|
}}
|
|
134
166
|
>
|
|
135
|
-
<div
|
|
167
|
+
<div
|
|
168
|
+
style={{
|
|
169
|
+
display: "flex",
|
|
170
|
+
alignItems: "center",
|
|
171
|
+
justifyContent: "space-between",
|
|
172
|
+
gap: "0.75rem",
|
|
173
|
+
flexWrap: "wrap",
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
136
176
|
{title ? <div style={{ fontWeight: 600, fontFamily: tokens.headingFontFamily }}>{title}</div> : <span />}
|
|
137
177
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
138
|
-
{recording && <RecordingIndicator tokens={tokens} />}
|
|
139
|
-
<ConnectionStatus status={status} tokens={tokens} onRetry={retry} />
|
|
178
|
+
{recording && <RecordingIndicator tokens={tokens} t={t} />}
|
|
179
|
+
<ConnectionStatus status={status} tokens={tokens} t={t} onRetry={retry} />
|
|
140
180
|
</div>
|
|
141
181
|
</div>
|
|
142
182
|
|
|
143
183
|
<div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.75rem", color: tokens.muted }}>
|
|
144
|
-
<span>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
<CallHelpHint label="Why can I hear someone but not see them?">
|
|
148
|
-
A tile with a name and no picture means that person has their camera off, or the call is busy enough that only
|
|
149
|
-
the people currently speaking are sent in full. Both are normal, and their audio is unaffected.
|
|
184
|
+
<span>{t("forge.call_stage.head_count", { count: callHeadCount(tiles) })}</span>
|
|
185
|
+
<CallHelpHint label={t("forge.call_stage.help_hear_not_see_label")}>
|
|
186
|
+
{t("forge.call_stage.help_hear_not_see")}
|
|
150
187
|
</CallHelpHint>
|
|
151
188
|
</div>
|
|
152
189
|
|
|
@@ -155,11 +192,34 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
155
192
|
display: "grid",
|
|
156
193
|
gridTemplateColumns: "repeat(auto-fit, minmax(12rem, 1fr))",
|
|
157
194
|
gap: "0.75rem",
|
|
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.
|
|
199
|
+
flex: 1,
|
|
200
|
+
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
|
+
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
|
+
justifyItems: "center",
|
|
158
218
|
}}
|
|
159
219
|
>
|
|
160
|
-
<LocalPreview tokens={tokens} aspectRatio={aspectRatio} />
|
|
220
|
+
<LocalPreview tokens={tokens} aspectRatio={aspectRatio} t={t} />
|
|
161
221
|
{tiles.map((tile) => (
|
|
162
|
-
<ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} />
|
|
222
|
+
<ParticipantTile key={tile.key} tile={tile} tokens={tokens} aspectRatio={aspectRatio} t={t} />
|
|
163
223
|
))}
|
|
164
224
|
</div>
|
|
165
225
|
|
|
@@ -167,7 +227,7 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
167
227
|
<RemoteAudio key={producerId} producerId={producerId} />
|
|
168
228
|
))}
|
|
169
229
|
|
|
170
|
-
<CallControls tokens={tokens} onLeave={onLeave} />
|
|
230
|
+
<CallControls tokens={tokens} t={t} connected={connectionState === "connected"} onLeave={onLeave} />
|
|
171
231
|
</div>
|
|
172
232
|
);
|
|
173
233
|
}
|
|
@@ -182,14 +242,18 @@ export function CallStage({ onLeave, title, aspectRatio = 16 / 9 }: CallStagePro
|
|
|
182
242
|
function ConnectionStatus({
|
|
183
243
|
status,
|
|
184
244
|
tokens,
|
|
245
|
+
t,
|
|
185
246
|
onRetry,
|
|
186
247
|
}: {
|
|
187
248
|
status: CallStatus;
|
|
188
249
|
tokens: ResolvedThemeTokens;
|
|
250
|
+
t: ForgeT;
|
|
189
251
|
onRetry: () => void;
|
|
190
252
|
}) {
|
|
191
253
|
const busy = status.tone === "connecting" || status.tone === "reconnecting";
|
|
192
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;
|
|
193
257
|
|
|
194
258
|
return (
|
|
195
259
|
<div
|
|
@@ -211,8 +275,8 @@ function ConnectionStatus({
|
|
|
211
275
|
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.primary, display: "inline-block" }}
|
|
212
276
|
/>
|
|
213
277
|
)}
|
|
214
|
-
<span style={{ fontWeight: alarming ? 600 : 400 }}>{status.
|
|
215
|
-
{
|
|
278
|
+
<span style={{ fontWeight: alarming ? 600 : 400 }}>{t(status.headlineKey)}</span>
|
|
279
|
+
{detail && <span style={{ color: tokens.muted }}>{detail}</span>}
|
|
216
280
|
{status.canRetry && (
|
|
217
281
|
<button
|
|
218
282
|
type="button"
|
|
@@ -232,7 +296,7 @@ function ConnectionStatus({
|
|
|
232
296
|
}}
|
|
233
297
|
>
|
|
234
298
|
<RefreshCw width={12} height={12} aria-hidden="true" />
|
|
235
|
-
|
|
299
|
+
{t("forge.call_stage.retry")}
|
|
236
300
|
</button>
|
|
237
301
|
)}
|
|
238
302
|
</div>
|
|
@@ -240,7 +304,7 @@ function ConnectionStatus({
|
|
|
240
304
|
}
|
|
241
305
|
|
|
242
306
|
/** Somebody is recording. A consent signal, so it is content and always shown. */
|
|
243
|
-
function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
|
|
307
|
+
function RecordingIndicator({ tokens, t }: { tokens: ResolvedThemeTokens; t: ForgeT }) {
|
|
244
308
|
return (
|
|
245
309
|
<span
|
|
246
310
|
style={{
|
|
@@ -259,7 +323,7 @@ function RecordingIndicator({ tokens }: { tokens: ResolvedThemeTokens }) {
|
|
|
259
323
|
aria-hidden="true"
|
|
260
324
|
style={{ width: 8, height: 8, borderRadius: 9999, backgroundColor: tokens.secondary, display: "inline-block" }}
|
|
261
325
|
/>
|
|
262
|
-
|
|
326
|
+
{t("forge.call_stage.recording")}
|
|
263
327
|
</span>
|
|
264
328
|
);
|
|
265
329
|
}
|
|
@@ -268,6 +332,10 @@ const tileFrame = (tokens: ResolvedThemeTokens, aspectRatio: number, speaking: b
|
|
|
268
332
|
position: "relative",
|
|
269
333
|
overflow: "hidden",
|
|
270
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%",
|
|
271
339
|
borderRadius: tokens.cornerRadius,
|
|
272
340
|
backgroundColor: tokens.surface,
|
|
273
341
|
// The active speaker is shown by the FRAME rather than by re-ordering the
|
|
@@ -306,10 +374,12 @@ function ParticipantTile({
|
|
|
306
374
|
tile,
|
|
307
375
|
tokens,
|
|
308
376
|
aspectRatio,
|
|
377
|
+
t,
|
|
309
378
|
}: {
|
|
310
379
|
tile: CallTile;
|
|
311
380
|
tokens: ResolvedThemeTokens;
|
|
312
381
|
aspectRatio: number;
|
|
382
|
+
t: ForgeT;
|
|
313
383
|
}) {
|
|
314
384
|
// `useRemoteTrack` takes a producer id unconditionally so the hook order is
|
|
315
385
|
// the same for a tile with video and a tile without. An id that matches
|
|
@@ -327,21 +397,23 @@ function ParticipantTile({
|
|
|
327
397
|
data-speaking={tile.isSpeaking ? "true" : "false"}
|
|
328
398
|
>
|
|
329
399
|
{showVideo ? (
|
|
330
|
-
<video
|
|
331
|
-
ref={attach}
|
|
332
|
-
autoPlay
|
|
333
|
-
playsInline
|
|
334
|
-
muted
|
|
335
|
-
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
336
|
-
/>
|
|
400
|
+
<video ref={attach} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
|
337
401
|
) : (
|
|
338
|
-
<span
|
|
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
|
+
>
|
|
339
411
|
{tile.name}
|
|
340
412
|
</span>
|
|
341
413
|
)}
|
|
342
414
|
<span style={tileName(tokens)}>
|
|
343
415
|
{tile.name}
|
|
344
|
-
{!tile.hasAudio && <span style={{ color: tokens.muted }}> (listening)</span>}
|
|
416
|
+
{!tile.hasAudio && <span style={{ color: tokens.muted }}> {t("forge.call_stage.listening")}</span>}
|
|
345
417
|
</span>
|
|
346
418
|
</div>
|
|
347
419
|
);
|
|
@@ -361,13 +433,15 @@ function RemoteAudio({ producerId }: { producerId: string }) {
|
|
|
361
433
|
}
|
|
362
434
|
|
|
363
435
|
/** This viewer, as they are being sent. Muted, or it is a feedback loop. */
|
|
364
|
-
function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; aspectRatio: number }) {
|
|
436
|
+
function LocalPreview({ tokens, aspectRatio, t }: { tokens: ResolvedThemeTokens; aspectRatio: number; t: ForgeT }) {
|
|
365
437
|
const state = useRoomState();
|
|
366
438
|
const publications = useLocalPublications();
|
|
367
439
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
368
440
|
|
|
369
441
|
const camera = useMemo(() => publications.find((p) => p.source === "camera"), [publications]);
|
|
370
|
-
|
|
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
|
+
const track = camera && !camera.paused ? camera.track : undefined;
|
|
371
445
|
|
|
372
446
|
useEffect(() => {
|
|
373
447
|
const element = videoRef.current;
|
|
@@ -381,7 +455,7 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
|
|
|
381
455
|
};
|
|
382
456
|
}, [track]);
|
|
383
457
|
|
|
384
|
-
const name = state.identity ? "
|
|
458
|
+
const name = state.identity ? t("forge.call_stage.you") : t("forge.call_stage.you_not_connected");
|
|
385
459
|
|
|
386
460
|
return (
|
|
387
461
|
<div style={tileFrame(tokens, aspectRatio, false)} data-testid="tile-local">
|
|
@@ -401,6 +475,13 @@ function LocalPreview({ tokens, aspectRatio }: { tokens: ResolvedThemeTokens; as
|
|
|
401
475
|
);
|
|
402
476
|
}
|
|
403
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
|
+
|
|
404
485
|
const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = false): CSSProperties => ({
|
|
405
486
|
display: "inline-flex",
|
|
406
487
|
alignItems: "center",
|
|
@@ -423,31 +504,66 @@ const controlButton = (tokens: ResolvedThemeTokens, active: boolean, busy = fals
|
|
|
423
504
|
* a listen-only seat is not offered a microphone it would be refused. That is a
|
|
424
505
|
* rendering decision; the node refuses the publish itself either way.
|
|
425
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
|
+
*
|
|
426
519
|
* ## Why every toggle is disabled while it is working
|
|
427
520
|
*
|
|
428
521
|
* Each of these is a read-then-write: it reads whether the source is live and
|
|
429
|
-
* publishes or
|
|
430
|
-
* the publish has resolved, and a publish starts with a permission
|
|
431
|
-
* can sit on screen for seconds - so a button that looks untouched
|
|
432
|
-
* a person presses again, and two presses used to mean two
|
|
433
|
-
* identity and everyone else hearing them doubled.
|
|
434
|
-
* real lock (a synchronous ref, since both clicks
|
|
435
|
-
* anything); this is the part that says so on
|
|
436
|
-
* silently ignores you is only marginally
|
|
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.
|
|
437
531
|
*/
|
|
438
|
-
function CallControls({
|
|
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
|
+
}) {
|
|
439
543
|
const grants = useGrants();
|
|
440
544
|
const { room } = useMediaRoom();
|
|
441
545
|
const media = useLocalMedia();
|
|
442
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
|
+
|
|
443
551
|
// Plain handlers, deliberately not `useCallback`. `useLocalMedia()` returns a
|
|
444
552
|
// fresh object every render, so memoising on it would rebuild the callback
|
|
445
553
|
// every render anyway while looking as though it did not.
|
|
446
554
|
const toggleMicrophone = () => {
|
|
447
|
-
|
|
555
|
+
if (!media.isMicrophoneEnabled) {
|
|
556
|
+
void media.publishMicrophone();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
void media.setPaused("microphone", microphoneLive);
|
|
448
560
|
};
|
|
449
561
|
const toggleCamera = () => {
|
|
450
|
-
|
|
562
|
+
if (!media.isCameraEnabled) {
|
|
563
|
+
void media.publishCamera();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
void media.setPaused("camera", cameraLive);
|
|
451
567
|
};
|
|
452
568
|
const toggleScreen = () => {
|
|
453
569
|
void (media.screenSharing ? media.unpublish("screen") : media.publishScreen());
|
|
@@ -462,39 +578,57 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
462
578
|
}, [room, onLeave]);
|
|
463
579
|
|
|
464
580
|
return (
|
|
465
|
-
<div
|
|
581
|
+
<div
|
|
582
|
+
style={{
|
|
583
|
+
display: "flex",
|
|
584
|
+
flexWrap: "wrap",
|
|
585
|
+
alignItems: "center",
|
|
586
|
+
gap: "0.5rem",
|
|
587
|
+
// Pinned to the bottom of the surface. The tile grid already claims the
|
|
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,
|
|
593
|
+
}}
|
|
594
|
+
>
|
|
466
595
|
{canPublishSource(grants, "microphone") && (
|
|
467
596
|
<button
|
|
468
597
|
type="button"
|
|
469
598
|
onClick={toggleMicrophone}
|
|
470
599
|
disabled={media.pending.microphone}
|
|
471
600
|
aria-busy={media.pending.microphone}
|
|
472
|
-
style={controlButton(tokens,
|
|
601
|
+
style={controlButton(tokens, microphoneLive, media.pending.microphone)}
|
|
473
602
|
>
|
|
474
|
-
{
|
|
603
|
+
{microphoneLive ? (
|
|
475
604
|
<Mic width={16} height={16} aria-hidden="true" />
|
|
476
605
|
) : (
|
|
477
606
|
<MicOff width={16} height={16} aria-hidden="true" />
|
|
478
607
|
)}
|
|
479
|
-
{
|
|
608
|
+
{microphoneLive ? t("forge.call_stage.mute") : t("forge.call_stage.unmute")}
|
|
480
609
|
</button>
|
|
481
610
|
)}
|
|
482
611
|
|
|
483
612
|
{canPublishSource(grants, "camera") && (
|
|
484
|
-
<
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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>
|
|
498
632
|
)}
|
|
499
633
|
|
|
500
634
|
{canPublishSource(grants, "screen") && (
|
|
@@ -511,11 +645,10 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
511
645
|
) : (
|
|
512
646
|
<MonitorUp width={16} height={16} aria-hidden="true" />
|
|
513
647
|
)}
|
|
514
|
-
{media.screenSharing ? "
|
|
648
|
+
{media.screenSharing ? t("forge.call_stage.screen_stop") : t("forge.call_stage.screen_start")}
|
|
515
649
|
</button>
|
|
516
|
-
<CallHelpHint label="
|
|
517
|
-
|
|
518
|
-
it rather than being replaced, so people can still see you while you present.
|
|
650
|
+
<CallHelpHint label={t("forge.call_stage.help_screen_label")}>
|
|
651
|
+
{t("forge.call_stage.help_screen")}
|
|
519
652
|
</CallHelpHint>
|
|
520
653
|
</span>
|
|
521
654
|
)}
|
|
@@ -526,7 +659,7 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
526
659
|
style={{ ...controlButton(tokens, false), marginLeft: "auto", borderColor: tokens.secondary }}
|
|
527
660
|
>
|
|
528
661
|
<PhoneOff width={16} height={16} aria-hidden="true" />
|
|
529
|
-
|
|
662
|
+
{t("forge.call_stage.leave")}
|
|
530
663
|
</button>
|
|
531
664
|
|
|
532
665
|
{/* A denied permission prompt is something the person has to be told
|
|
@@ -537,6 +670,22 @@ function CallControls({ tokens, onLeave }: { tokens: ResolvedThemeTokens; onLeav
|
|
|
537
670
|
{media.error.message}
|
|
538
671
|
</span>
|
|
539
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
|
+
)}
|
|
540
689
|
</div>
|
|
541
690
|
);
|
|
542
691
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
4
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a call page shows when there is no call to join yet, or any more.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is not the connection status
|
|
10
|
+
*
|
|
11
|
+
* A page that mounts `<CallStage>` regardless of the window tries to connect,
|
|
12
|
+
* fails, and reports the only thing it knows: "not yet connected". That is true
|
|
13
|
+
* and useless. Somebody who followed a link from their confirmation email is
|
|
14
|
+
* asking one of two questions, and the server already answered both in the same
|
|
15
|
+
* read that produced this page: WHEN can I come in, and HAVE I missed it.
|
|
16
|
+
*
|
|
17
|
+
* ## Why the times are rendered, not the raw window
|
|
18
|
+
*
|
|
19
|
+
* `opensAt` is the server's own boundary, the same one it enforces when it
|
|
20
|
+
* refuses a ticket, so what this prints is what the server will honour rather
|
|
21
|
+
* than a client-side guess. It is formatted in the reader's locale and, where
|
|
22
|
+
* the session carries one, the session's timezone: a coaching client in Lagos
|
|
23
|
+
* looking at a Berlin coach's session should be told the time they will
|
|
24
|
+
* actually be sitting at their desk.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately NOT a countdown. A ticking clock invites somebody to sit and
|
|
27
|
+
* watch it, and the page is already re-fetched when they return.
|
|
28
|
+
*/
|
|
29
|
+
export type CallWindowNoticeProps = {
|
|
30
|
+
/** The earliest the server will mint a ticket. */
|
|
31
|
+
opensAt: string | Date;
|
|
32
|
+
/** After this it refuses new tickets; anyone already inside stays inside. */
|
|
33
|
+
closesAt: string | Date;
|
|
34
|
+
/** The call's own title, so the reader knows which session this is. */
|
|
35
|
+
title?: string;
|
|
36
|
+
/** IANA zone the session was sold in, when there is one. */
|
|
37
|
+
timeZone?: string | null;
|
|
38
|
+
/** Injected by specs; defaults to the clock. */
|
|
39
|
+
now?: Date;
|
|
40
|
+
/** A way back, rendered under the message when given. */
|
|
41
|
+
action?: ReactNode;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function CallWindowNotice({ opensAt, closesAt, title, timeZone, now, action }: CallWindowNoticeProps) {
|
|
45
|
+
const t = useForgeT();
|
|
46
|
+
const tokens = useThemeTokens();
|
|
47
|
+
|
|
48
|
+
const opens = new Date(opensAt);
|
|
49
|
+
const closes = new Date(closesAt);
|
|
50
|
+
const at = now ?? new Date();
|
|
51
|
+
|
|
52
|
+
const when = opens.toLocaleString(undefined, {
|
|
53
|
+
weekday: "short",
|
|
54
|
+
day: "numeric",
|
|
55
|
+
month: "short",
|
|
56
|
+
hour: "numeric",
|
|
57
|
+
minute: "2-digit",
|
|
58
|
+
timeZone: timeZone ?? undefined,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Three states, and the middle one is the reason this component exists: a
|
|
62
|
+
// call that is open but unreachable is the connection status's business, not
|
|
63
|
+
// this component's, so it renders nothing and lets `<CallStage>` speak.
|
|
64
|
+
const tooEarly = at < opens;
|
|
65
|
+
const tooLate = at > closes;
|
|
66
|
+
if (!tooEarly && !tooLate) return null;
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
style={{
|
|
71
|
+
height: "100%",
|
|
72
|
+
display: "flex",
|
|
73
|
+
flexDirection: "column",
|
|
74
|
+
alignItems: "center",
|
|
75
|
+
justifyContent: "center",
|
|
76
|
+
gap: "0.5rem",
|
|
77
|
+
padding: "2rem 1.5rem",
|
|
78
|
+
textAlign: "center",
|
|
79
|
+
color: tokens.text,
|
|
80
|
+
backgroundColor: tokens.background,
|
|
81
|
+
borderRadius: tokens.cornerRadius,
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
{title && <div style={{ fontWeight: 600, fontSize: "1.125rem" }}>{title}</div>}
|
|
85
|
+
<div style={{ fontSize: "1rem" }}>
|
|
86
|
+
{tooEarly ? t("forge.call_window.not_started") : t("forge.call_window.ended")}
|
|
87
|
+
</div>
|
|
88
|
+
<div style={{ fontSize: "0.875rem", opacity: 0.7, maxWidth: "32rem" }}>
|
|
89
|
+
{tooEarly ? t("forge.call_window.opens_at", { datetime: when }) : t("forge.call_window.ended_detail")}
|
|
90
|
+
</div>
|
|
91
|
+
{action && <div style={{ marginTop: "0.75rem" }}>{action}</div>}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|