@tribe-nest/forge 3.37.0 → 3.39.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 +1 -1
- package/src/data/queries/useBroadcasts.ts +62 -7
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/types/models.ts +18 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +6 -2
- package/src/ui/headless/work/index.ts +4 -0
- package/src/ui/headless/work/useWorkPortal.ts +125 -10
- package/src/ui/media/_tests/CallStage.spec.tsx +1 -0
- package/src/ui/styled/_tests/broadcastStage.spec.ts +316 -0
- package/src/ui/styled/_tests/uiEntryServerSafety.spec.ts +127 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +233 -14
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +260 -0
- package/src/ui/styled/broadcast/broadcastStage.ts +189 -0
- package/src/ui/styled/work/WorkProjectDocuments.tsx +224 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +5 -3
- package/src/ui/styled/work/index.ts +1 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react";
|
|
2
2
|
import { Maximize, Pause, Pin, Play, Send } from "lucide-react";
|
|
3
3
|
import { io, type Socket } from "socket.io-client";
|
|
4
4
|
import type { IBroadcastPass, ILiveBroadcast } from "../../../types/models";
|
|
@@ -10,12 +10,25 @@ import {
|
|
|
10
10
|
useBroadcastComments,
|
|
11
11
|
useBroadcastSessionApi,
|
|
12
12
|
type BroadcastComment,
|
|
13
|
+
type BroadcastViewerCredentials,
|
|
13
14
|
} from "../../../data/queries/useBroadcasts";
|
|
14
15
|
import { useForgeT } from "../../../i18n";
|
|
15
16
|
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
17
|
+
import { CallHelpHint } from "../../media/CallHelpHint";
|
|
16
18
|
import { timeAgo } from "../community/util";
|
|
17
19
|
import { buttonStyle } from "../Button";
|
|
18
20
|
import { Loading } from "../Loading";
|
|
21
|
+
import {
|
|
22
|
+
broadcastFellBack,
|
|
23
|
+
chooseBroadcastStage,
|
|
24
|
+
type BroadcastCredentialsError,
|
|
25
|
+
type BroadcastRoomState,
|
|
26
|
+
} from "./broadcastStage";
|
|
27
|
+
// Type-only, and it has to stay that way: the module it names imports
|
|
28
|
+
// `mediasoup-client`, and a value import here would put a browser-only package
|
|
29
|
+
// on the server path of every site that loads `@tribe-nest/forge/ui`. It is
|
|
30
|
+
// reached through the `import()` in the effect below, which no server runs.
|
|
31
|
+
import type { BroadcastRealtimeStageProps } from "./BroadcastRealtimeStage";
|
|
19
32
|
|
|
20
33
|
/** The socket messages a watching browser sends and receives. */
|
|
21
34
|
const SocketEvent = {
|
|
@@ -35,6 +48,26 @@ const SIMULCAST_QUALITIES = [
|
|
|
35
48
|
{ label: "forge.broadcast_player.quality_low", value: "q" },
|
|
36
49
|
] as const;
|
|
37
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The token endpoint's refusal, reduced to what the decision needs.
|
|
53
|
+
*
|
|
54
|
+
* Nothing here distinguishes a 409 from a 500 from a dead network, and that is
|
|
55
|
+
* the point: every one of them means this viewer is not getting into the live
|
|
56
|
+
* room, and every one of them ends with them watching the stream instead. The
|
|
57
|
+
* status and the message are carried so a site can log which it was.
|
|
58
|
+
*/
|
|
59
|
+
function credentialsRefusal(error: unknown): BroadcastCredentialsError {
|
|
60
|
+
const response = (error as { response?: { status?: number; data?: { reason?: unknown; message?: unknown } } } | null)
|
|
61
|
+
?.response;
|
|
62
|
+
const status = response?.status;
|
|
63
|
+
// The 409 answers with `{ reason }` (`full` or `unavailable`); everything
|
|
64
|
+
// else on this API answers with `{ message }`. Either is worth keeping for a
|
|
65
|
+
// log, and neither changes what happens next.
|
|
66
|
+
const detail = response?.data?.reason ?? response?.data?.message;
|
|
67
|
+
const message = typeof detail === "string" ? detail : undefined;
|
|
68
|
+
return { ...(typeof status === "number" ? { status } : {}), ...(message ? { message } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
38
71
|
const newId = (): string => {
|
|
39
72
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
40
73
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
@@ -65,9 +98,35 @@ export interface BroadcastPlayerProps {
|
|
|
65
98
|
/**
|
|
66
99
|
* The watch surface: the stream, who else is here, and the chat.
|
|
67
100
|
*
|
|
68
|
-
* Mounted ONLY behind a validated pass.
|
|
69
|
-
* broadcast itself
|
|
70
|
-
*
|
|
101
|
+
* Mounted ONLY behind a validated pass. Three playback paths, chosen by the
|
|
102
|
+
* broadcast itself and never by the viewer:
|
|
103
|
+
*
|
|
104
|
+
* 1. **The media plane** (`realtime.available`). A v2 broadcast whose live room
|
|
105
|
+
* is open, watched over WebRTC in well under a second. This is the path that
|
|
106
|
+
* can be REFUSED: the room has a hard cap, so being turned away is ordinary.
|
|
107
|
+
* 2. **Cloudflare Calls** (`realtimeConfig`). The v1 realtime path, unchanged
|
|
108
|
+
* and still carrying its quality picker, until v1 itself is retired.
|
|
109
|
+
* 3. **HLS.** Six to ten seconds behind and always available.
|
|
110
|
+
*
|
|
111
|
+
* ## Falling back is the feature, not the error path
|
|
112
|
+
*
|
|
113
|
+
* A viewer who cannot get onto the plane must end up watching the broadcast,
|
|
114
|
+
* without doing anything and without being asked anything. So every uncertain
|
|
115
|
+
* state resolves to HLS, and the four ways the plane can fail (the token
|
|
116
|
+
* endpoint answering 409, any other failure fetching one, the room closing, and
|
|
117
|
+
* the SDK giving up) all land in the same place. `chooseBroadcastStage` holds
|
|
118
|
+
* the rule and `broadcastStage.spec.ts` walks every combination of it.
|
|
119
|
+
*
|
|
120
|
+
* The one thing the viewer is told is a single line saying which stream they
|
|
121
|
+
* are on, because a stream that is suddenly eight seconds behind a chat they
|
|
122
|
+
* are reading is something a person needs an explanation for. Why it happened
|
|
123
|
+
* is behind the `?`, per the house design law.
|
|
124
|
+
*
|
|
125
|
+
* ## Why the quality picker is not on the new path
|
|
126
|
+
*
|
|
127
|
+
* The plane has no simulcast. Every viewer receives the program feed's single
|
|
128
|
+
* encoding, so High, Medium and Low would be three controls that change
|
|
129
|
+
* nothing. A control that lies about what it does is worse than no control.
|
|
71
130
|
*/
|
|
72
131
|
export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
|
|
73
132
|
const t = useForgeT();
|
|
@@ -92,10 +151,124 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
92
151
|
const [realtimeSessionId, setRealtimeSessionId] = useState<string | null>(null);
|
|
93
152
|
const isSubscribingRef = useRef(false);
|
|
94
153
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
154
|
+
const realtimeAvailable = broadcast.realtime?.available === true;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The v1 Cloudflare path, and it yields to the plane rather than competing
|
|
158
|
+
* with it. A broadcast carries one or the other in practice; if one ever
|
|
159
|
+
* carried both, the newer plane is the one to attempt, and a plane that
|
|
160
|
+
* refuses falls back to HLS as the contract says rather than sideways into a
|
|
161
|
+
* v1 session that will not exist.
|
|
162
|
+
*/
|
|
163
|
+
const isLegacyRealtime = useMemo(
|
|
164
|
+
() => !realtimeAvailable && !!broadcast.realtimeConfig && Object.keys(broadcast.realtimeConfig).length > 0,
|
|
165
|
+
[realtimeAvailable, broadcast.realtimeConfig],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// ── The media plane ────────────────────────────────────────────────────────
|
|
169
|
+
const [PlaneStage, setPlaneStage] = useState<ComponentType<BroadcastRealtimeStageProps> | null>(null);
|
|
170
|
+
const [credentialsReady, setCredentialsReady] = useState(false);
|
|
171
|
+
const [credentialsError, setCredentialsError] = useState<BroadcastCredentialsError | null>(null);
|
|
172
|
+
const [roomState, setRoomState] = useState<BroadcastRoomState | undefined>(undefined);
|
|
173
|
+
/** The ticket the probe already minted, spent on the SDK's FIRST attempt. */
|
|
174
|
+
const primedCredentials = useRef<BroadcastViewerCredentials | null>(null);
|
|
175
|
+
|
|
176
|
+
// `sessionApi` is rebuilt every render, so it is read through a ref rather
|
|
177
|
+
// than depended on: as a dependency it would re-mint a ticket on every render.
|
|
178
|
+
const sessionApiRef = useRef(sessionApi);
|
|
179
|
+
sessionApiRef.current = sessionApi;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The pass is the credential this endpoint takes, and `sessionId` is the id
|
|
183
|
+
* the pass is known by everywhere else on this surface (it is what
|
|
184
|
+
* `validate-session` is given, and `viewer-token` validates the pass the same
|
|
185
|
+
* way before minting).
|
|
186
|
+
*/
|
|
187
|
+
const passId = broadcastPass.sessionId;
|
|
188
|
+
|
|
189
|
+
const mintCredentials = useCallback(
|
|
190
|
+
() => sessionApiRef.current.viewerToken({ broadcastId: broadcast.id, ...(passId ? { passId } : {}) }),
|
|
191
|
+
[broadcast.id, passId],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Load the SDK and mint a ticket, together, in an effect.
|
|
196
|
+
*
|
|
197
|
+
* The effect is what makes this safe on a server: Forge renders inside
|
|
198
|
+
* TanStack Start on Cloudflare Workers, `BroadcastRealtimeStage` imports
|
|
199
|
+
* `mediasoup-client`, and an effect is code no server ever runs. Nothing on
|
|
200
|
+
* the module path of this file touches the media SDK.
|
|
201
|
+
*
|
|
202
|
+
* They are fetched together because neither is any use without the other, and
|
|
203
|
+
* because a viewer must not be moved onto the plane until BOTH are in hand:
|
|
204
|
+
* mounting the provider first would show a black rectangle for as long as the
|
|
205
|
+
* chunk took to arrive. A chunk that never arrives lands in the same `catch`
|
|
206
|
+
* as a refused ticket and falls back the same way.
|
|
207
|
+
*/
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!realtimeAvailable) return;
|
|
210
|
+
let cancelled = false;
|
|
211
|
+
|
|
212
|
+
Promise.all([import("./BroadcastRealtimeStage"), mintCredentials()])
|
|
213
|
+
.then(([module, credentials]) => {
|
|
214
|
+
if (cancelled) return;
|
|
215
|
+
primedCredentials.current = credentials;
|
|
216
|
+
setPlaneStage(() => module.BroadcastRealtimeStage);
|
|
217
|
+
setCredentialsReady(true);
|
|
218
|
+
})
|
|
219
|
+
.catch((error: unknown) => {
|
|
220
|
+
if (cancelled) return;
|
|
221
|
+
setCredentialsError(credentialsRefusal(error));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return () => {
|
|
225
|
+
cancelled = true;
|
|
226
|
+
};
|
|
227
|
+
}, [realtimeAvailable, mintCredentials]);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Handed to the provider, which calls it before EVERY attempt rather than
|
|
231
|
+
* once. A viewer ticket expires in ten minutes and a broadcast runs longer,
|
|
232
|
+
* so a token fetched once means the first reconnect presents a dead one.
|
|
233
|
+
*
|
|
234
|
+
* The probe's ticket is spent here on the first call, so one viewer does not
|
|
235
|
+
* mint two identities to watch one broadcast.
|
|
236
|
+
*/
|
|
237
|
+
const getCredentials = useCallback(async () => {
|
|
238
|
+
const primed = primedCredentials.current;
|
|
239
|
+
primedCredentials.current = null;
|
|
240
|
+
if (primed) return primed;
|
|
241
|
+
try {
|
|
242
|
+
return await mintCredentials();
|
|
243
|
+
} catch (error) {
|
|
244
|
+
// A refusal on a reconnect says the same thing as a refusal on the first
|
|
245
|
+
// attempt: this viewer is not getting back into the room. Recording it is
|
|
246
|
+
// what moves them onto HLS instead of leaving the SDK knocking.
|
|
247
|
+
setCredentialsError(credentialsRefusal(error));
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
}, [mintCredentials]);
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Never cleared back to `undefined` when the stage unmounts, and that is
|
|
254
|
+
* load-bearing. A room that has closed is the reason we left it, so forgetting
|
|
255
|
+
* it would choose the plane again, mount the provider again, and watch it
|
|
256
|
+
* close again, for ever.
|
|
257
|
+
*/
|
|
258
|
+
const handleRoomState = useCallback((next: BroadcastRoomState) => setRoomState(next), []);
|
|
259
|
+
|
|
260
|
+
const stageInput = useMemo(
|
|
261
|
+
() => ({ realtimeAvailable, credentialsReady, credentialsError, roomState }),
|
|
262
|
+
[realtimeAvailable, credentialsReady, credentialsError, roomState],
|
|
98
263
|
);
|
|
264
|
+
const stage = chooseBroadcastStage(stageInput);
|
|
265
|
+
const showPlane = stage === "realtime" && !!PlaneStage;
|
|
266
|
+
// Nothing is mounted while the ticket is in flight. `react-player` builds an
|
|
267
|
+
// hls.js instance on mount and starts fetching immediately, so rendering the
|
|
268
|
+
// HLS stage "meanwhile" made every realtime viewer download the broadcast a
|
|
269
|
+
// second time, over the CDN, for a stage that was about to be replaced.
|
|
270
|
+
const showHls = stage === "hls" && !isLegacyRealtime;
|
|
271
|
+
const fellBack = broadcastFellBack(stageInput);
|
|
99
272
|
|
|
100
273
|
const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
|
|
101
274
|
const { data: initialComments } = useBroadcastComments(broadcast.id);
|
|
@@ -113,10 +286,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
113
286
|
});
|
|
114
287
|
}, [comments]);
|
|
115
288
|
|
|
116
|
-
// ── WebRTC subscription
|
|
289
|
+
// ── WebRTC subscription, v1 (Cloudflare Calls) ─────────────────────────────
|
|
117
290
|
useEffect(() => {
|
|
118
291
|
const config = broadcast.realtimeConfig;
|
|
119
|
-
if (!
|
|
292
|
+
if (!isLegacyRealtime || !config) return;
|
|
120
293
|
if (isSubscribingRef.current) return;
|
|
121
294
|
isSubscribingRef.current = true;
|
|
122
295
|
|
|
@@ -150,6 +323,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
150
323
|
type: local?.type,
|
|
151
324
|
trackIds: config.tracks.map((track) => track.trackName),
|
|
152
325
|
sessionId: config.sessionId,
|
|
326
|
+
// The pass, because subscribing IS playback: this endpoint used to
|
|
327
|
+
// take a session id and a track name from anybody and hand back the
|
|
328
|
+
// stream, which made the ticket box in front of it decoration.
|
|
329
|
+
passId,
|
|
153
330
|
});
|
|
154
331
|
|
|
155
332
|
setRealtimeSessionId(answer.sessionId);
|
|
@@ -166,7 +343,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
166
343
|
// `sessionApi` is rebuilt each render and is deliberately not a dependency:
|
|
167
344
|
// including it would re-negotiate the peer connection on every render.
|
|
168
345
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
169
|
-
}, [
|
|
346
|
+
}, [isLegacyRealtime, broadcast.realtimeConfig]);
|
|
170
347
|
|
|
171
348
|
// ── Chat socket ────────────────────────────────────────────────────────────
|
|
172
349
|
useEffect(() => {
|
|
@@ -213,7 +390,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
213
390
|
};
|
|
214
391
|
|
|
215
392
|
const togglePlay = () => {
|
|
216
|
-
if (!
|
|
393
|
+
if (!isLegacyRealtime || !isAudioLoaded || !isVideoLoaded) return;
|
|
217
394
|
if (isPlaying) {
|
|
218
395
|
audioRef.current?.pause();
|
|
219
396
|
videoRef.current?.pause();
|
|
@@ -239,6 +416,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
239
416
|
const videoTrack = config.tracks.find((track) => track.mid === "0");
|
|
240
417
|
try {
|
|
241
418
|
await sessionApi.switchQuality({
|
|
419
|
+
passId,
|
|
242
420
|
track: {
|
|
243
421
|
...videoTrack,
|
|
244
422
|
sessionId: realtimeSessionId,
|
|
@@ -283,7 +461,15 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
283
461
|
}}
|
|
284
462
|
>
|
|
285
463
|
<div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
|
|
286
|
-
{
|
|
464
|
+
{showPlane && PlaneStage && <PlaneStage getCredentials={getCredentials} onRoomState={handleRoomState} />}
|
|
465
|
+
|
|
466
|
+
{stage === "pending" && (
|
|
467
|
+
<div style={{ aspectRatio: "16/9", display: "grid", placeItems: "center" }}>
|
|
468
|
+
<Loading />
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
|
|
472
|
+
{showHls && (
|
|
287
473
|
<div data-testid="broadcast-hls-stage" style={{ aspectRatio: "16/9" }}>
|
|
288
474
|
{renderPlayer ? (
|
|
289
475
|
renderPlayer({ src: broadcast.liveUrl })
|
|
@@ -299,7 +485,34 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
299
485
|
</div>
|
|
300
486
|
)}
|
|
301
487
|
|
|
302
|
-
{
|
|
488
|
+
{/*
|
|
489
|
+
Which stream they ended up on. This is STATE, not an explanation, so
|
|
490
|
+
it stays visible: a viewer whose picture is suddenly eight seconds
|
|
491
|
+
behind the chat beside it needs to know that before they go looking
|
|
492
|
+
for a fault. The reason it happened is help, so it is behind the `?`.
|
|
493
|
+
*/}
|
|
494
|
+
{fellBack && (
|
|
495
|
+
<div
|
|
496
|
+
role="status"
|
|
497
|
+
data-testid="broadcast-fallback-notice"
|
|
498
|
+
style={{
|
|
499
|
+
display: "flex",
|
|
500
|
+
alignItems: "center",
|
|
501
|
+
gap: 6,
|
|
502
|
+
padding: "8px 16px 0",
|
|
503
|
+
fontSize: 13,
|
|
504
|
+
color: theme.colors.text,
|
|
505
|
+
opacity: 0.75,
|
|
506
|
+
}}
|
|
507
|
+
>
|
|
508
|
+
<span>{t("forge.broadcast_player.fallback_notice")}</span>
|
|
509
|
+
<CallHelpHint label={t("forge.broadcast_player.fallback_help_label")}>
|
|
510
|
+
{t("forge.broadcast_player.fallback_help")}
|
|
511
|
+
</CallHelpHint>
|
|
512
|
+
</div>
|
|
513
|
+
)}
|
|
514
|
+
|
|
515
|
+
{isLegacyRealtime && (
|
|
303
516
|
<div
|
|
304
517
|
ref={videoContainerRef}
|
|
305
518
|
data-testid="broadcast-realtime-stage"
|
|
@@ -403,7 +616,13 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
403
616
|
</div>
|
|
404
617
|
</div>
|
|
405
618
|
|
|
406
|
-
{
|
|
619
|
+
{/*
|
|
620
|
+
v1 only. The media plane has no simulcast, so on that path these
|
|
621
|
+
four options would all resolve to the one encoding the program feed
|
|
622
|
+
publishes, and a picker that cannot change anything is a lie about
|
|
623
|
+
what the viewer is receiving.
|
|
624
|
+
*/}
|
|
625
|
+
{isLegacyRealtime && (
|
|
407
626
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
408
627
|
<label htmlFor="broadcast-quality">{t("forge.broadcast_player.quality")}</label>
|
|
409
628
|
<select
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Maximize, Volume2, VolumeX } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import type { MediaRoomCredentials } from "@tribe-nest/media-client";
|
|
5
|
+
import {
|
|
6
|
+
MediaRoomProvider,
|
|
7
|
+
useMediaRoom,
|
|
8
|
+
useRemoteTrack,
|
|
9
|
+
useRoomState,
|
|
10
|
+
useVisibleProducers,
|
|
11
|
+
} from "@tribe-nest/media-client/react";
|
|
12
|
+
|
|
13
|
+
import { useForgeT } from "../../../i18n";
|
|
14
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
15
|
+
import { Loading } from "../Loading";
|
|
16
|
+
import { programTracks, type BroadcastRoomState } from "./broadcastStage";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A live broadcast, received over the platform's own media plane.
|
|
20
|
+
*
|
|
21
|
+
* ## Why this is its own file, and why nothing imports it directly
|
|
22
|
+
*
|
|
23
|
+
* `@tribe-nest/media-client/react` pulls in `mediasoup-client`, which touches
|
|
24
|
+
* `RTCPeerConnection` at module scope and does not exist on a server. Forge
|
|
25
|
+
* renders inside TanStack Start on Cloudflare Workers, and `BroadcastPlayer`
|
|
26
|
+
* lives in the `./ui` entry that every site loads on every page, so a static
|
|
27
|
+
* import of the SDK from the player would put a browser-only module on the
|
|
28
|
+
* server path of sites that have never held a broadcast. So the player reaches
|
|
29
|
+
* this file through a dynamic `import()` fired from an effect, which is code
|
|
30
|
+
* that cannot run anywhere but a browser. `./media` (the coaching call surface)
|
|
31
|
+
* is a separate entry that a site opts into, which is why it may import the SDK
|
|
32
|
+
* at module scope and this may not.
|
|
33
|
+
*
|
|
34
|
+
* ## What it renders
|
|
35
|
+
*
|
|
36
|
+
* One publisher, `program:<broadcastId>`, with one video producer and one audio
|
|
37
|
+
* producer. The viewer's token can subscribe to that identity and nothing else,
|
|
38
|
+
* so the producers this sees ARE the broadcast (see `programTracks`).
|
|
39
|
+
*
|
|
40
|
+
* ## Sound is a toggle, and play/pause is not offered
|
|
41
|
+
*
|
|
42
|
+
* The picture starts on its own, muted, because a muted autoplay is the one
|
|
43
|
+
* thing every browser allows without a gesture and a viewer who has arrived at
|
|
44
|
+
* a live stream should see it. Sound needs the gesture, so it gets the control.
|
|
45
|
+
*
|
|
46
|
+
* What it deliberately does NOT get is a pause button. There is nothing to
|
|
47
|
+
* resume: this is a subscription to what is happening now, so a "pause" is a
|
|
48
|
+
* mute plus a blank screen plus a lie about being able to go back. The HLS
|
|
49
|
+
* stage keeps its transport controls because there the buffer makes them true.
|
|
50
|
+
*
|
|
51
|
+
* There is no quality selector either. The plane has no simulcast: every viewer
|
|
52
|
+
* receives the program feed's single encoding, so a picker offering High,
|
|
53
|
+
* Medium and Low would be three buttons that all do the same nothing.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
export type BroadcastRealtimeStageProps = {
|
|
57
|
+
/**
|
|
58
|
+
* Called before EVERY attempt, not once, which is why it is a callback. A
|
|
59
|
+
* viewer credential expires in ten minutes and a broadcast runs for longer,
|
|
60
|
+
* so a token fetched once means the first reconnect presents a dead one.
|
|
61
|
+
*/
|
|
62
|
+
getCredentials: () => Promise<MediaRoomCredentials>;
|
|
63
|
+
/**
|
|
64
|
+
* The room, reported upward on every change.
|
|
65
|
+
*
|
|
66
|
+
* The decision to fall back to HLS is made by the PLAYER, above the provider,
|
|
67
|
+
* because falling back means unmounting the provider and a component cannot
|
|
68
|
+
* unmount itself. So this reports and the player decides, and there is one
|
|
69
|
+
* `chooseBroadcastStage` rather than one here and another one up there.
|
|
70
|
+
*/
|
|
71
|
+
onRoomState: (state: BroadcastRoomState) => void;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function BroadcastRealtimeStage({ getCredentials, onRoomState }: BroadcastRealtimeStageProps) {
|
|
75
|
+
return (
|
|
76
|
+
<MediaRoomProvider getCredentials={getCredentials} autoSubscribe>
|
|
77
|
+
<ProgramStage onRoomState={onRoomState} />
|
|
78
|
+
</MediaRoomProvider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function ProgramStage({ onRoomState }: { onRoomState: (state: BroadcastRoomState) => void }) {
|
|
83
|
+
const t = useForgeT();
|
|
84
|
+
const theme = useForgeTheme();
|
|
85
|
+
const { connectionState, error, recovering } = useMediaRoom();
|
|
86
|
+
const state = useRoomState();
|
|
87
|
+
const producers = useVisibleProducers();
|
|
88
|
+
|
|
89
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
90
|
+
const [soundOn, setSoundOn] = useState(false);
|
|
91
|
+
const [showControls, setShowControls] = useState(false);
|
|
92
|
+
|
|
93
|
+
// Memoised on the room's own snapshot, which is a stable reference while
|
|
94
|
+
// nothing has changed. Rebuilding it per render is what turns a stage into a
|
|
95
|
+
// component that re-renders per frame.
|
|
96
|
+
const tracks = useMemo(() => programTracks(producers), [producers]);
|
|
97
|
+
const { track, attach } = useRemoteTrack(tracks.videoProducerId ?? "");
|
|
98
|
+
const hasPicture = !!tracks.videoProducerId && !!track && !tracks.videoPaused;
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
onRoomState({ connectionState, phase: state.phase, recovering, ...(error ? { error } : {}) });
|
|
102
|
+
}, [onRoomState, connectionState, state.phase, recovering, error]);
|
|
103
|
+
|
|
104
|
+
const toggleFullscreen = () => {
|
|
105
|
+
if (!containerRef.current) return;
|
|
106
|
+
if (document.fullscreenElement) void document.exitFullscreen();
|
|
107
|
+
else void containerRef.current.requestFullscreen();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
ref={containerRef}
|
|
113
|
+
data-testid="broadcast-plane-stage"
|
|
114
|
+
onMouseEnter={() => setShowControls(true)}
|
|
115
|
+
onMouseLeave={() => setShowControls(false)}
|
|
116
|
+
// The ratio is on BOTH this box and the video inside it, and that is not
|
|
117
|
+
// redundancy for its own sake. The container's ratio is what guarantees
|
|
118
|
+
// the panel has a height at all, including before a single frame has
|
|
119
|
+
// arrived; removing it once collapsed the stage and the chat beside it
|
|
120
|
+
// rode up into the gap. The video's own ratio (below) is what keeps that
|
|
121
|
+
// height still while simulcast changes the resolution underneath it.
|
|
122
|
+
// They compute to the same box, so nothing fights.
|
|
123
|
+
style={{ aspectRatio: "16 / 9", position: "relative", background: "#000", lineHeight: 0 }}
|
|
124
|
+
>
|
|
125
|
+
{/*
|
|
126
|
+
Muted, always, and that is not the sound control. The audio arrives as
|
|
127
|
+
its own producer on its own element, so a viewer who unmuted THIS would
|
|
128
|
+
hear nothing and conclude the stream is silent.
|
|
129
|
+
*/}
|
|
130
|
+
{/*
|
|
131
|
+
The RATIO is on the video itself, which is what keeps the box still.
|
|
132
|
+
|
|
133
|
+
Simulcast changes the incoming resolution underneath us as the node
|
|
134
|
+
moves a viewer between layers (1080p to 540p to 270p). A replaced
|
|
135
|
+
element takes its box partly from the track's intrinsic size, and
|
|
136
|
+
`height: 100%` did not save it: a percentage height needs a definite
|
|
137
|
+
parent height, and one derived from the container's own `aspect-ratio`
|
|
138
|
+
is not reliably that. So the element fell back to intrinsic height and
|
|
139
|
+
the whole panel jumped on every layer switch, at the one moment a
|
|
140
|
+
viewer should notice nothing.
|
|
141
|
+
|
|
142
|
+
`aspect-ratio` on the element with `height: auto` overrides the
|
|
143
|
+
intrinsic ratio outright: the box is 16/9 of whatever width it is
|
|
144
|
+
given, whatever arrives on the wire. `height: 100%` is deliberately
|
|
145
|
+
NOT used, because that is the thing that failed.
|
|
146
|
+
*/}
|
|
147
|
+
<video
|
|
148
|
+
ref={attach}
|
|
149
|
+
autoPlay
|
|
150
|
+
playsInline
|
|
151
|
+
muted
|
|
152
|
+
style={{ display: "block", width: "100%", height: "auto", aspectRatio: "16 / 9", objectFit: "contain" }}
|
|
153
|
+
/>
|
|
154
|
+
|
|
155
|
+
{tracks.audioProducerIds.map((producerId) => (
|
|
156
|
+
<ProgramAudio key={producerId} producerId={producerId} soundOn={soundOn} />
|
|
157
|
+
))}
|
|
158
|
+
|
|
159
|
+
{!hasPicture && (
|
|
160
|
+
<div
|
|
161
|
+
style={{
|
|
162
|
+
position: "absolute",
|
|
163
|
+
top: "50%",
|
|
164
|
+
left: "50%",
|
|
165
|
+
transform: "translate(-50%, -50%)",
|
|
166
|
+
pointerEvents: "none",
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<Loading />
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{hasPicture && (
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
onClick={() => setSoundOn((was) => !was)}
|
|
177
|
+
aria-label={soundOn ? t("forge.broadcast_player.sound_off") : t("forge.broadcast_player.sound_on")}
|
|
178
|
+
style={{
|
|
179
|
+
position: "absolute",
|
|
180
|
+
bottom: 16,
|
|
181
|
+
left: 16,
|
|
182
|
+
padding: 8,
|
|
183
|
+
borderRadius: theme.cornerRadius,
|
|
184
|
+
border: "none",
|
|
185
|
+
cursor: "pointer",
|
|
186
|
+
background: "rgba(0, 0, 0, 0.5)",
|
|
187
|
+
color: "#ffffff",
|
|
188
|
+
zIndex: 1,
|
|
189
|
+
// Kept on screen while the sound is off, because it is the control
|
|
190
|
+
// a viewer arriving at a silent stream is looking for. Once the
|
|
191
|
+
// sound is on it behaves like the rest of the chrome.
|
|
192
|
+
opacity: soundOn && !showControls ? 0 : 1,
|
|
193
|
+
transition: "opacity 150ms",
|
|
194
|
+
}}
|
|
195
|
+
>
|
|
196
|
+
{soundOn ? <Volume2 size={20} /> : <VolumeX size={20} />}
|
|
197
|
+
</button>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{hasPicture && (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={toggleFullscreen}
|
|
204
|
+
aria-label={t("forge.broadcast_player.fullscreen")}
|
|
205
|
+
style={{
|
|
206
|
+
position: "absolute",
|
|
207
|
+
bottom: 16,
|
|
208
|
+
right: 16,
|
|
209
|
+
padding: 8,
|
|
210
|
+
borderRadius: theme.cornerRadius,
|
|
211
|
+
border: "none",
|
|
212
|
+
cursor: "pointer",
|
|
213
|
+
background: "rgba(0, 0, 0, 0.5)",
|
|
214
|
+
color: "#ffffff",
|
|
215
|
+
zIndex: 1,
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<Maximize size={20} />
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The program's audio, attached and never drawn.
|
|
227
|
+
*
|
|
228
|
+
* Held on its own element rather than on the `<video>`, because the two arrive
|
|
229
|
+
* as separate producers and the video element is muted for good (see above).
|
|
230
|
+
*
|
|
231
|
+
* `play()` is called from an effect rather than through `autoPlay` so that a
|
|
232
|
+
* producer arriving AFTER the viewer turned the sound on is audible too: an
|
|
233
|
+
* `autoPlay` attribute is read when the element mounts, and a track that lands
|
|
234
|
+
* later would leave a broadcast playing in silence with the speaker icon on.
|
|
235
|
+
* The rejection is swallowed on purpose: a browser refusing to play unprompted
|
|
236
|
+
* audio is the ordinary case this control exists to get past, not an error.
|
|
237
|
+
*/
|
|
238
|
+
function ProgramAudio({ producerId, soundOn }: { producerId: string; soundOn: boolean }) {
|
|
239
|
+
const { track, attach } = useRemoteTrack(producerId);
|
|
240
|
+
const elementRef = useRef<HTMLAudioElement | null>(null);
|
|
241
|
+
|
|
242
|
+
const setElement = useCallback(
|
|
243
|
+
(element: HTMLAudioElement | null) => {
|
|
244
|
+
elementRef.current = element;
|
|
245
|
+
attach(element);
|
|
246
|
+
},
|
|
247
|
+
[attach],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const element = elementRef.current;
|
|
252
|
+
if (!element) return;
|
|
253
|
+
if (soundOn) void element.play().catch(() => undefined);
|
|
254
|
+
else element.pause();
|
|
255
|
+
}, [soundOn, track]);
|
|
256
|
+
|
|
257
|
+
return <audio ref={setElement} style={{ display: "none" }} />;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export default BroadcastRealtimeStage;
|