@tribe-nest/forge 3.41.1 → 3.42.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.41.1",
3
+ "version": "3.42.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -31,7 +31,7 @@
31
31
  "@stripe/react-stripe-js": "^3.8.0",
32
32
  "@stripe/stripe-js": "^7.6.1",
33
33
  "@tanstack/react-query": "^5.62.7",
34
- "@tribe-nest/media-client": "^0.1.0",
34
+ "@tribe-nest/media-client": "^0.2.1",
35
35
  "@tribe-nest/media-protocol": "^1.0.0",
36
36
  "axios": "^1.7.9",
37
37
  "lucide-react": "^0.510.0",
@@ -30,12 +30,23 @@ import { join } from "path";
30
30
  * creator site, while the whole suite stayed green and the starter's release
31
31
  * notes announced the feature.
32
32
  *
33
- * So there are two properties here and they are separate:
33
+ * So there are three properties here and they are separate:
34
34
  *
35
35
  * 1. every bare import in the published tree names a package the consumer's
36
36
  * install will actually produce (a dependency, or a peer that npm installs
37
- * because it is NOT marked optional), and
38
- * 2. every workspace package Forge depends on is one npm will publish.
37
+ * because it is NOT marked optional),
38
+ * 2. every workspace package Forge depends on is one npm will publish, and
39
+ * 3. the RANGE Forge declares for a workspace package accepts the version that
40
+ * package is actually at.
41
+ *
42
+ * The third failed too, and silently. Forge went on declaring
43
+ * `"@tribe-nest/media-client": "^0.1.0"` after that package moved to `0.2.0`,
44
+ * and `^0.1.0` does not accept `0.2.x`. Inside the monorepo nothing shows it -
45
+ * the symlink means every build, every spec and every `check-types` run sees
46
+ * the 0.2 source - while a creator site resolved 0.1.1 from npm and got a
47
+ * `useRemoteTrack` that never detached a media element, so a remount left an
48
+ * orphan still playing the call's audio. The published surface was a minor
49
+ * version behind the surface the tests were green against.
39
50
  *
40
51
  * Specs are excluded: they never ship (`files` is `["src"]`, but a consumer
41
52
  * never imports a `.spec` file), and they legitimately reach for vitest, jsdom
@@ -47,6 +58,7 @@ const PACKAGES_ROOT = join(FORGE_ROOT, "..");
47
58
 
48
59
  type PackageJson = {
49
60
  name: string;
61
+ version?: string;
50
62
  private?: boolean;
51
63
  files?: string[];
52
64
  exports?: Record<string, unknown>;
@@ -181,4 +193,70 @@ describe("what a creator site's install has to produce", () => {
181
193
  it("still exports the media entry the Forge manifest advertises", () => {
182
194
  expect(forge.exports?.["./media"]).toBe("./src/ui/media/index.ts");
183
195
  });
196
+
197
+ /**
198
+ * The declared range has to accept the version the package is AT.
199
+ *
200
+ * Deliberately hand-rolled rather than reaching for `semver`: this package
201
+ * declares its dependencies honestly (that is the whole subject of this
202
+ * file), and `semver` is only present here as somebody else's transitive.
203
+ * The cost is that only the range forms this repo actually uses are
204
+ * understood - anything else fails loudly rather than passing vacuously,
205
+ * which is the right way round for a guard.
206
+ */
207
+ const satisfies = (range: string, version: string): boolean => {
208
+ const parse = (value: string): [number, number, number] => {
209
+ const parts = value.split(".").map((n) => Number.parseInt(n, 10));
210
+ if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
211
+ throw new Error(`not a plain semver version: ${value}`);
212
+ }
213
+ return [parts[0]!, parts[1]!, parts[2]!];
214
+ };
215
+ const atLeast = (a: [number, number, number], b: [number, number, number]) =>
216
+ a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] >= b[2];
217
+
218
+ const [major, minor, patch] = parse(version);
219
+ if (range === "*") return true;
220
+ if (/^\d/.test(range)) return range === version; // exact pin
221
+ if (range.startsWith("^")) {
222
+ const floor = parse(range.slice(1));
223
+ if (!atLeast([major, minor, patch], floor)) return false;
224
+ // Caret on a 0.x version pins the MINOR, which is where a pre-1.0
225
+ // package puts its breaking changes. `^0.1.0` does not accept `0.2.0`,
226
+ // and that is exactly the case this test exists for.
227
+ return floor[0] === 0 ? minor === floor[1] : major === floor[0];
228
+ }
229
+ throw new Error(`unsupported range form "${range}" - teach this helper before using it`);
230
+ };
231
+
232
+ it("declares a range that accepts each workspace package's current version", () => {
233
+ const workspaceDeps = Object.entries(forge.dependencies ?? {}).filter(([name]) =>
234
+ name.startsWith("@tribe-nest/"),
235
+ );
236
+ expect(workspaceDeps.length).toBeGreaterThan(0);
237
+
238
+ const stale = workspaceDeps
239
+ .map(([name, range]) => {
240
+ const dependency = readPackage(join(PACKAGES_ROOT, name.replace("@tribe-nest/", "")));
241
+ return { name, range, version: dependency.version };
242
+ })
243
+ .filter(({ range, version }) => !satisfies(range, version!))
244
+ .map(({ name, range, version }) => `${name}: declared ${range}, package is at ${version}`);
245
+
246
+ expect(stale).toEqual([]);
247
+ });
248
+
249
+ it("the range check itself knows that a 0.x caret pins the minor", () => {
250
+ // Without this the test above is only as good as a helper nobody checked,
251
+ // and the bug it guards is precisely a 0.x caret being read as permissive.
252
+ expect(satisfies("^0.1.0", "0.1.1")).toBe(true);
253
+ expect(satisfies("^0.1.0", "0.2.0")).toBe(false);
254
+ expect(satisfies("^0.2.1", "0.2.1")).toBe(true);
255
+ expect(satisfies("^0.2.1", "0.2.0")).toBe(false);
256
+ expect(satisfies("^1.0.0", "1.0.1")).toBe(true);
257
+ expect(satisfies("^1.0.0", "2.0.0")).toBe(false);
258
+ expect(satisfies("1.0.1", "1.0.1")).toBe(true);
259
+ expect(satisfies("1.0.1", "1.0.2")).toBe(false);
260
+ expect(() => satisfies(">=1.0.0", "1.0.0")).toThrow();
261
+ });
184
262
  });
@@ -1,6 +1,14 @@
1
1
  import { useForge } from "../../provider/ForgeProvider";
2
2
  import { useMutation } from "@tanstack/react-query";
3
3
 
4
+ /**
5
+ * Where a Forge site's reset-password page lives.
6
+ *
7
+ * Every functional page on a code website is under `/i/`, and the emailed link
8
+ * is built from whatever the site sends here.
9
+ */
10
+ export const DEFAULT_RESET_PASSWORD_PATH = "/i/reset-password";
11
+
4
12
  // Stateless account actions (no session state) — kept out of PublicAuthContext.
5
13
  //
6
14
  // ## Why these hit `/public/sessions/*` and not `/public/accounts/*`
@@ -33,8 +41,8 @@ import { useMutation } from "@tanstack/react-query";
33
41
  export function useForgotPassword() {
34
42
  const { client, profileId } = useForge();
35
43
 
36
- return useMutation<unknown, unknown, { email: string; origin?: string; profileId?: string }>({
37
- mutationFn: async ({ email, origin, profileId: overrideProfileId }) => {
44
+ return useMutation<unknown, unknown, { email: string; origin?: string; profileId?: string; resetPath?: string }>({
45
+ mutationFn: async ({ email, origin, profileId: overrideProfileId, resetPath }) => {
38
46
  const contextId = overrideProfileId ?? profileId;
39
47
  if (!contextId) {
40
48
  // Better than posting without it: the endpoint would 400 on schema
@@ -47,6 +55,11 @@ export function useForgotPassword() {
47
55
  // The reset link is built server-side from this, and the association
48
56
  // endpoint requires it (the global one treated it as optional).
49
57
  origin: origin ?? (typeof window !== "undefined" ? window.location.origin : undefined),
58
+ // And the PATH, because the backend cannot guess it. Its default is
59
+ // `apps/client`'s `/reset-password`, which does not exist on a code
60
+ // website - every functional page there is under `/i/`, so the emailed
61
+ // link 404'd for everyone on the current default storefront.
62
+ resetPath: resetPath ?? DEFAULT_RESET_PASSWORD_PATH,
50
63
  });
51
64
  return res.data;
52
65
  },
@@ -6,12 +6,18 @@ import { useForgeTheme } from "../theme/ForgeThemeProvider";
6
6
  export interface ForgotPasswordFormProps {
7
7
  /** Href for the "Back to login" / "Login" links. */
8
8
  loginHref: string;
9
+ /**
10
+ * The path the emailed reset link points at, on this site's own origin.
11
+ * Defaults to `/i/reset-password`, which is where the starter puts it. Pass
12
+ * one only if the site moved that route.
13
+ */
14
+ resetPath?: string;
9
15
  }
10
16
 
11
17
  const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
12
18
 
13
19
  /** Themed forgot-password request form, built on `useForgotPassword`. */
14
- export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
20
+ export function ForgotPasswordForm({ loginHref, resetPath }: ForgotPasswordFormProps) {
15
21
  const t = useForgeT();
16
22
  const theme = useForgeTheme();
17
23
  const forgotPassword = useForgotPassword();
@@ -50,7 +56,11 @@ export function ForgotPasswordForm({ loginHref }: ForgotPasswordFormProps) {
50
56
  setError("");
51
57
  setIsSubmitting(true);
52
58
  try {
53
- await forgotPassword.mutateAsync({ email, origin: window.location.origin });
59
+ await forgotPassword.mutateAsync({
60
+ email,
61
+ origin: window.location.origin,
62
+ ...(resetPath ? { resetPath } : {}),
63
+ });
54
64
  setIsSuccess(true);
55
65
  } catch (err) {
56
66
  setError(msg(err) || t("forge.forgot_password_form.error"));
@@ -314,3 +314,44 @@ describe("programTracks", () => {
314
314
  expect(tracks.audioProducerIds).toEqual(["a2"]);
315
315
  });
316
316
  });
317
+
318
+ describe("the relay lane", () => {
319
+ const base = {
320
+ realtimeAvailable: true,
321
+ credentialsReady: true,
322
+ credentialsError: null,
323
+ } as const;
324
+
325
+ it("prefers the relay over the plane when both are offered", () => {
326
+ expect(chooseBroadcastStage({ ...base, moqAvailable: true })).toBe("moq");
327
+ });
328
+
329
+ it("is not a fallback: a working relay is the best lane there is", () => {
330
+ expect(broadcastFellBack({ ...base, moqAvailable: true })).toBe(false);
331
+ });
332
+
333
+ it("falls to the plane when the relay lane is over and a plane is offered", () => {
334
+ expect(chooseBroadcastStage({ ...base, moqAvailable: true, moqFailed: true })).toBe("realtime");
335
+ });
336
+
337
+ // The shape of a relay-only instance: `viewer-token` answered with `moq` and
338
+ // `credentials: null`. Falling to "realtime" here would mount a room that
339
+ // does not exist and show a black rectangle for ever.
340
+ it("falls to hls when the relay is over and there is no plane behind it", () => {
341
+ expect(
342
+ chooseBroadcastStage({ ...base, moqAvailable: true, moqFailed: true, planeAvailable: false }),
343
+ ).toBe("hls");
344
+ expect(
345
+ broadcastFellBack({ ...base, moqAvailable: true, moqFailed: true, planeAvailable: false }),
346
+ ).toBe(true);
347
+ });
348
+
349
+ it("still draws nothing while the ticket is in flight", () => {
350
+ expect(chooseBroadcastStage({ ...base, credentialsReady: false, moqAvailable: true })).toBe("pending");
351
+ });
352
+
353
+ it("leaves every existing caller unchanged", () => {
354
+ expect(chooseBroadcastStage(base)).toBe("realtime");
355
+ expect(chooseBroadcastStage({ ...base, realtimeAvailable: false })).toBe("hls");
356
+ });
357
+ });
@@ -0,0 +1,124 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ /**
4
+ * The relay stage: a broadcast watched over MoQ.
5
+ *
6
+ * The player is a VENDORED bundle (`vendor/moq-watch.bundle.js`), not an npm
7
+ * dependency, because Forge ships source: its `exports` point at `./src/*.ts`
8
+ * with no build step, so anything it depends on is something every creator site
9
+ * has to resolve too, and `@moq/json` peer-depends on zod 4 against this repo's
10
+ * zod 3. See `scripts/build-moq-player.mjs`.
11
+ *
12
+ * Loaded LAZILY by the player, never imported on this file's module path,
13
+ * for the same reason `BroadcastRealtimeStage` is: Forge renders inside
14
+ * TanStack Start on Cloudflare Workers, and `@moq/watch` reaches for
15
+ * WebTransport, WebCodecs and a decoder worker the moment it is evaluated.
16
+ * An effect is code no server ever runs.
17
+ *
18
+ * The element is created imperatively rather than written as JSX because it is
19
+ * a custom element: JSX would need an ambient declaration in every consuming
20
+ * app, and `document.createElement` needs none.
21
+ */
22
+
23
+ export type BroadcastMoqStageProps = {
24
+ /** The relay URL with the viewer ticket already on it. */
25
+ url: string;
26
+ /** The broadcast name beneath the ticket's root. */
27
+ name: string;
28
+ muted: boolean;
29
+ /**
30
+ * The lane is over: the player should fall back.
31
+ *
32
+ * Reported upward rather than handled here for the same reason the plane
33
+ * stage reports its room state upward: falling back means unmounting this
34
+ * component, and a component cannot unmount itself. There is one
35
+ * `chooseBroadcastStage` and it lives above.
36
+ */
37
+ onFailed: (reason: string) => void;
38
+ };
39
+
40
+ export function BroadcastMoqStage({ url, name, muted, onFailed }: BroadcastMoqStageProps) {
41
+ const hostRef = useRef<HTMLDivElement>(null);
42
+ // Held in a ref so the effect below does not re-run when the player
43
+ // re-renders with a new closure, which would tear down a working player.
44
+ const onFailedRef = useRef(onFailed);
45
+ onFailedRef.current = onFailed;
46
+
47
+ useEffect(() => {
48
+ const host = hostRef.current;
49
+ if (!host) return;
50
+
51
+ let cancelled = false;
52
+ let element: HTMLElement | undefined;
53
+
54
+ const mount = async () => {
55
+ try {
56
+ // Imported for its side effect: the module registers <moq-watch>.
57
+ await import("./vendor/moq-watch.bundle.js");
58
+ } catch (error) {
59
+ // The chunk did not load at all. Identical in consequence to a refused
60
+ // ticket, and it falls back the same way.
61
+ onFailedRef.current(`moq player failed to load: ${(error as Error).message}`);
62
+ return;
63
+ }
64
+ if (cancelled) return;
65
+
66
+ element = document.createElement("moq-watch");
67
+ element.setAttribute("url", url);
68
+ element.setAttribute("name", name);
69
+ if (muted) element.setAttribute("muted", "");
70
+ /**
71
+ * `always`, not the default.
72
+ *
73
+ * The default is a visibility THRESHOLD ("20%") driven by an
74
+ * IntersectionObserver, and until it is met the renderer reports itself
75
+ * hidden, which disables the decoder, which never subscribes to the video
76
+ * track. A player inside a tab that has not been scrolled, or measured
77
+ * before layout settles, shows black for ever rather than late.
78
+ */
79
+ element.setAttribute("visible", "always");
80
+ element.style.width = "100%";
81
+ element.style.height = "100%";
82
+ element.style.display = "block";
83
+
84
+ /**
85
+ * The nested canvas the renderer paints into, and it is REQUIRED.
86
+ *
87
+ * `<moq-watch>` finds its render target by querying its own children. With
88
+ * no canvas there is no target, so the renderer is never visible, so
89
+ * `video.in.enabled` stays false and the decoder never subscribes. Every
90
+ * symptom of this is silent: the connection is established, the catalog
91
+ * arrives with the video rendition, the config resolves, and the relay
92
+ * shows only a `catalog.json` subscription and never a `video` one.
93
+ */
94
+ const canvas = document.createElement("canvas");
95
+ canvas.style.width = "100%";
96
+ canvas.style.height = "100%";
97
+ canvas.style.display = "block";
98
+ element.appendChild(canvas);
99
+
100
+ // A relay that refuses the ticket, or a broadcast that is not there,
101
+ // surfaces as an error event rather than a throw: the element owns its
102
+ // own reconnect loop and never rejects the caller.
103
+ element.addEventListener("error", () => {
104
+ onFailedRef.current("the relay connection failed");
105
+ });
106
+
107
+ host.replaceChildren(element);
108
+ };
109
+
110
+ void mount();
111
+
112
+ return () => {
113
+ cancelled = true;
114
+ // `replaceChildren()` disconnects the element, and its
115
+ // `disconnectedCallback` closes the connection and the decoders. Leaving
116
+ // it attached would keep a QUIC session and a decode pipeline alive for a
117
+ // stage the viewer has already left.
118
+ host.replaceChildren();
119
+ element = undefined;
120
+ };
121
+ }, [url, name, muted]);
122
+
123
+ return <div ref={hostRef} className="h-full w-full bg-black" />;
124
+ }
@@ -28,7 +28,26 @@ import {
28
28
  // `mediasoup-client`, and a value import here would put a browser-only package
29
29
  // on the server path of every site that loads `@tribe-nest/forge/ui`. It is
30
30
  // reached through the `import()` in the effect below, which no server runs.
31
+ import type { BroadcastMoqStageProps } from "./BroadcastMoqStage";
31
32
  import type { BroadcastRealtimeStageProps } from "./BroadcastRealtimeStage";
33
+
34
+ /** The relay lane's half of a `viewer-token` response. */
35
+ type BroadcastMoqCredentials = { url: string; name: string; expiresAt: string; path: string };
36
+
37
+ /**
38
+ * Renders the lazily-loaded relay stage.
39
+ *
40
+ * A named component rather than `<moqStage ... />` inline: a component read out
41
+ * of state and rendered directly is a new type on every render that produces a
42
+ * different one, which unmounts and remounts the player underneath. Naming it
43
+ * here keeps the element type stable across renders.
44
+ */
45
+ function MoqStageRenderer({
46
+ Stage,
47
+ ...props
48
+ }: BroadcastMoqStageProps & { Stage: ComponentType<BroadcastMoqStageProps> }) {
49
+ return <Stage {...props} />;
50
+ }
32
51
  import { BroadcastPollPanel } from "./BroadcastPollPanel";
33
52
  import { useBroadcastPoll } from "../../headless/broadcast/useBroadcastPoll";
34
53
  import type { BroadcastPoll } from "../../../data/queries/useBroadcasts";
@@ -132,9 +151,13 @@ export interface BroadcastPlayerProps {
132
151
  *
133
152
  * ## Why the quality picker is not on the new path
134
153
  *
135
- * The plane has no simulcast. Every viewer receives the program feed's single
136
- * encoding, so High, Medium and Low would be three controls that change
137
- * nothing. A control that lies about what it does is worse than no control.
154
+ * The plane DOES publish three simulcast layers now (`REALTIME_PROGRAM_VIDEO`),
155
+ * so the old reason for leaving the picker off - "there is only one encoding,
156
+ * three buttons would all do nothing" - no longer holds. What holds instead is
157
+ * that the node's own layer choice tracks the viewer's actual bandwidth, and a
158
+ * manual override is a way to pin yourself to a layer your connection cannot
159
+ * carry. The picker below stays on the v1 Cloudflare path, which is where it
160
+ * was built and where it is wired.
138
161
  */
139
162
  export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
140
163
  const t = useForgeT();
@@ -178,6 +201,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
178
201
  const [PlaneStage, setPlaneStage] = useState<ComponentType<BroadcastRealtimeStageProps> | null>(null);
179
202
  const [credentialsReady, setCredentialsReady] = useState(false);
180
203
  const [credentialsError, setCredentialsError] = useState<BroadcastCredentialsError | null>(null);
204
+ /**
205
+ * The relay lane, as `viewer-token` reported it.
206
+ *
207
+ * Held separately from the plane credentials because the two are independent
208
+ * answers: an instance may offer a relay and no plane, a plane and no relay,
209
+ * or both. `planeAvailable` reads the plane's half off the same response.
210
+ */
211
+ const [moqStage, setMoqStage] = useState<ComponentType<BroadcastMoqStageProps> | null>(null);
212
+ const [moqCredentials, setMoqCredentials] = useState<BroadcastMoqCredentials | null>(null);
213
+ const [moqFailed, setMoqFailed] = useState(false);
214
+ const [planeAvailable, setPlaneAvailable] = useState(true);
181
215
  const [roomState, setRoomState] = useState<BroadcastRoomState | undefined>(undefined);
182
216
  /** The ticket the probe already minted, spent on the SDK's FIRST attempt. */
183
217
  const primedCredentials = useRef<BroadcastViewerCredentials | null>(null);
@@ -223,6 +257,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
223
257
  if (cancelled) return;
224
258
  primedCredentials.current = credentials;
225
259
  setPlaneStage(() => module.BroadcastRealtimeStage);
260
+ // A response with no `mediaUrl` is a relay-only instance. Recording it
261
+ // is what stops the chooser falling to a plane room that is not there
262
+ // if the relay lane later gives up.
263
+ setPlaneAvailable(Boolean((credentials as { mediaUrl?: string }).mediaUrl));
264
+ const moq = (credentials as { moq?: BroadcastMoqCredentials }).moq ?? null;
265
+ setMoqCredentials(moq);
266
+ if (moq) {
267
+ import("./BroadcastMoqStage")
268
+ .then((moqModule) => {
269
+ if (cancelled) return;
270
+ setMoqStage(() => moqModule.BroadcastMoqStage);
271
+ })
272
+ .catch(() => {
273
+ if (cancelled) return;
274
+ // The relay chunk did not load. The lane is over before it began.
275
+ setMoqFailed(true);
276
+ });
277
+ }
226
278
  setCredentialsReady(true);
227
279
  })
228
280
  .catch((error: unknown) => {
@@ -266,11 +318,27 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
266
318
  */
267
319
  const handleRoomState = useCallback((next: BroadcastRoomState) => setRoomState(next), []);
268
320
 
321
+ /**
322
+ * The relay lane gave up. Never cleared back to false, for the same reason
323
+ * `roomState` is never cleared: the failure is why we left, so forgetting it
324
+ * would choose the relay again, mount it again, and watch it fail again.
325
+ */
326
+ const handleMoqFailed = useCallback(() => setMoqFailed(true), []);
327
+
269
328
  const stageInput = useMemo(
270
- () => ({ realtimeAvailable, credentialsReady, credentialsError, roomState }),
271
- [realtimeAvailable, credentialsReady, credentialsError, roomState],
329
+ () => ({
330
+ realtimeAvailable,
331
+ credentialsReady,
332
+ credentialsError,
333
+ roomState,
334
+ moqAvailable: Boolean(moqCredentials && moqStage),
335
+ moqFailed,
336
+ planeAvailable,
337
+ }),
338
+ [realtimeAvailable, credentialsReady, credentialsError, roomState, moqCredentials, moqStage, moqFailed, planeAvailable],
272
339
  );
273
340
  const stage = chooseBroadcastStage(stageInput);
341
+ const showMoq = stage === "moq" && !!moqStage && !!moqCredentials;
274
342
  const showPlane = stage === "realtime" && !!PlaneStage;
275
343
  // Nothing is mounted while the ticket is in flight. `react-player` builds an
276
344
  // hls.js instance on mount and starts fetching immediately, so rendering the
@@ -481,6 +549,19 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
481
549
  }}
482
550
  >
483
551
  <div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
552
+ {showMoq && moqStage && moqCredentials && (
553
+ <MoqStageRenderer
554
+ Stage={moqStage}
555
+ url={moqCredentials.url}
556
+ name={moqCredentials.name}
557
+ // Not muted: this is the broadcast the viewer came for, and the
558
+ // plane stage does not mute either. The element paints video to a
559
+ // canvas independently of its audio emitter, so a browser that
560
+ // blocks autoplay audio still shows a picture rather than nothing.
561
+ muted={false}
562
+ onFailed={handleMoqFailed}
563
+ />
564
+ )}
484
565
  {showPlane && PlaneStage && <PlaneStage getCredentials={getCredentials} onRoomState={handleRoomState} />}
485
566
 
486
567
  {stage === "pending" && (
@@ -490,7 +571,26 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
490
571
  )}
491
572
 
492
573
  {showHls && (
493
- <div data-testid="broadcast-hls-stage" style={{ aspectRatio: "16/9" }}>
574
+ /*
575
+ The ratio box, and INSIDE it an absolutely filled box that the
576
+ player cannot resize.
577
+
578
+ `renderPlayer` is host-supplied (the starter passes `react-player`),
579
+ so this cannot dictate how the player sizes itself - and the
580
+ conventional `height: "100%"` a player is given resolves against a
581
+ parent whose height comes from `aspect-ratio`, which is not reliably
582
+ a definite height. The player then falls back to the stream's
583
+ intrinsic size and the panel jumps every time HLS changes rendition.
584
+
585
+ An absolutely positioned `inset: 0` box HAS a definite height, so a
586
+ child asking for 100% gets a real answer, and nothing the child does
587
+ can change the box it is in.
588
+ */
589
+ <div
590
+ data-testid="broadcast-hls-stage"
591
+ style={{ aspectRatio: "16/9", position: "relative", lineHeight: 0 }}
592
+ >
593
+ <div style={{ position: "absolute", inset: 0 }}>
494
594
  {renderPlayer ? (
495
595
  renderPlayer({ src: broadcast.liveUrl })
496
596
  ) : (
@@ -499,9 +599,20 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
499
599
  controls
500
600
  autoPlay
501
601
  playsInline
502
- style={{ width: "100%", height: "100%", background: "#000" }}
602
+ // Same reasoning as the realtime stage below: HLS renditions
603
+ // change resolution mid-stream too, and `height: 100%` off an
604
+ // `aspect-ratio` parent leaves the box following the source.
605
+ style={{
606
+ display: "block",
607
+ width: "100%",
608
+ height: "auto",
609
+ aspectRatio: "16 / 9",
610
+ objectFit: "contain",
611
+ background: "#000",
612
+ }}
503
613
  />
504
614
  )}
615
+ </div>
505
616
  </div>
506
617
  )}
507
618
 
@@ -539,9 +650,44 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
539
650
  onClick={togglePlay}
540
651
  onMouseEnter={() => setShowControls(true)}
541
652
  onMouseLeave={() => setShowControls(false)}
542
- style={{ aspectRatio: "16/9", position: "relative", background: "#000", cursor: "pointer" }}
653
+ style={{
654
+ aspectRatio: "16/9",
655
+ position: "relative",
656
+ background: "#000",
657
+ cursor: "pointer",
658
+ lineHeight: 0,
659
+ }}
543
660
  >
544
- <video ref={videoRef} playsInline style={{ width: "100%", height: "100%" }} />
661
+ {/*
662
+ The RATIO is on the video element itself, and `height` is `auto`.
663
+
664
+ This is the same fix `BroadcastRealtimeStage` carries, and this
665
+ path is where it was actually needed: it is the one with a quality
666
+ picker, so the incoming resolution changes whenever the viewer
667
+ chooses a layer (or the auto policy chooses one for them).
668
+
669
+ `height: 100%` did not hold the box still. A percentage height
670
+ needs a definite parent height, and one derived from the parent's
671
+ own `aspect-ratio` is not reliably that, so the replaced element
672
+ fell back to the track's INTRINSIC height and the whole panel
673
+ jumped on every quality change. `aspect-ratio` with `height: auto`
674
+ overrides the intrinsic ratio outright: the box is 16/9 of
675
+ whatever width it is given, whatever arrives on the wire.
676
+
677
+ `object-fit: contain` then letterboxes a source that is not 16/9
678
+ inside that fixed box rather than resizing the box to suit it.
679
+ */}
680
+ <video
681
+ ref={videoRef}
682
+ playsInline
683
+ style={{
684
+ display: "block",
685
+ width: "100%",
686
+ height: "auto",
687
+ aspectRatio: "16 / 9",
688
+ objectFit: "contain",
689
+ }}
690
+ />
545
691
  <audio ref={audioRef} />
546
692
 
547
693
  {(!isAudioLoaded || !isVideoLoaded) && (
@@ -48,9 +48,12 @@ import { programTracks, type BroadcastRoomState } from "./broadcastStage";
48
48
  * mute plus a blank screen plus a lie about being able to go back. The HLS
49
49
  * stage keeps its transport controls because there the buffer makes them true.
50
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.
51
+ * There is no quality selector either, though not for the reason there used to
52
+ * be: the program feed publishes three simulcast layers, and the node moves a
53
+ * viewer between them on its own reading of their connection. A manual override
54
+ * is mostly a way to pin yourself above what your link can carry, so the choice
55
+ * stays with the node. What this file owes the viewer instead is that the
56
+ * SWITCH is invisible, which is what the sizing below is about.
54
57
  */
55
58
 
56
59
  export type BroadcastRealtimeStageProps = {