@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.
@@ -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. Two playback paths, chosen by the
69
- * broadcast itself: a WebRTC subscription when the broadcast carries a realtime
70
- * config (sub-second, with a quality picker), and HLS otherwise.
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 isRealtime = useMemo(
96
- () => !!broadcast.realtimeConfig && Object.keys(broadcast.realtimeConfig).length > 0,
97
- [broadcast.realtimeConfig],
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 (!isRealtime || !config) return;
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
- }, [isRealtime, broadcast.realtimeConfig]);
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 (!isRealtime || !isAudioLoaded || !isVideoLoaded) return;
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
- {!isRealtime && (
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
- {isRealtime && (
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
- {isRealtime && (
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;