@tribe-nest/forge 3.42.0 → 3.42.1
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
|
@@ -159,6 +159,14 @@ export interface BroadcastPlayerProps {
|
|
|
159
159
|
* carry. The picker below stays on the v1 Cloudflare path, which is where it
|
|
160
160
|
* was built and where it is wired.
|
|
161
161
|
*/
|
|
162
|
+
/**
|
|
163
|
+
* How long the v1 Cloudflare lane may deliver nothing before HLS takes over.
|
|
164
|
+
*
|
|
165
|
+
* Long enough that a slow negotiation is not cut off, short enough that a
|
|
166
|
+
* viewer is not staring at a spinner while the broadcast is being published.
|
|
167
|
+
*/
|
|
168
|
+
const LEGACY_REALTIME_DEADLINE_MS = 12_000;
|
|
169
|
+
|
|
162
170
|
export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
|
|
163
171
|
const t = useForgeT();
|
|
164
172
|
const theme = useForgeTheme();
|
|
@@ -175,6 +183,21 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
175
183
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
176
184
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
177
185
|
const videoContainerRef = useRef<HTMLDivElement>(null);
|
|
186
|
+
/**
|
|
187
|
+
* The v1 Cloudflare lane gave up, so HLS may have the viewer.
|
|
188
|
+
*
|
|
189
|
+
* The plane and the relay both fall back: every way they can fail ends at
|
|
190
|
+
* HLS, and `chooseBroadcastStage` holds that rule. The v1 lane had no such
|
|
191
|
+
* exit. `isLegacyRealtime` SUPPRESSED the HLS stage outright, so a broadcast
|
|
192
|
+
* whose Cloudflare room was gone - retired, expired, never created for a v2
|
|
193
|
+
* broadcast that still carries a stale `realtimeConfig` - showed a spinner
|
|
194
|
+
* and nothing else, for ever, with no way for the viewer to reach a stream
|
|
195
|
+
* that was being published the whole time.
|
|
196
|
+
*
|
|
197
|
+
* Reported from production as a live HLS broadcast showing nothing on the
|
|
198
|
+
* site.
|
|
199
|
+
*/
|
|
200
|
+
const [legacyFailed, setLegacyFailed] = useState(false);
|
|
178
201
|
const [isAudioLoaded, setIsAudioLoaded] = useState(false);
|
|
179
202
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
180
203
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
@@ -344,7 +367,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
344
367
|
// hls.js instance on mount and starts fetching immediately, so rendering the
|
|
345
368
|
// HLS stage "meanwhile" made every realtime viewer download the broadcast a
|
|
346
369
|
// second time, over the CDN, for a stage that was about to be replaced.
|
|
347
|
-
const showHls = stage === "hls" && !isLegacyRealtime;
|
|
370
|
+
const showHls = stage === "hls" && (!isLegacyRealtime || legacyFailed);
|
|
348
371
|
const fellBack = broadcastFellBack(stageInput);
|
|
349
372
|
|
|
350
373
|
const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
|
|
@@ -363,6 +386,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
363
386
|
});
|
|
364
387
|
}, [comments]);
|
|
365
388
|
|
|
389
|
+
/**
|
|
390
|
+
* The v1 lane also fails by never arriving, which no `catch` will report.
|
|
391
|
+
*
|
|
392
|
+
* A peer connection that negotiates and then delivers no track raises
|
|
393
|
+
* nothing at all: `subscribe()` resolves, and the viewer watches a spinner.
|
|
394
|
+
* So the deadline is the backstop, and it is what makes the fallback
|
|
395
|
+
* guaranteed rather than dependent on the failure being the polite kind.
|
|
396
|
+
*
|
|
397
|
+
* Twelve seconds: long enough that a slow negotiation is not cut off, short
|
|
398
|
+
* enough that a viewer is not staring at nothing while the broadcast runs.
|
|
399
|
+
*/
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (!isLegacyRealtime || legacyFailed) return;
|
|
402
|
+
if (isVideoLoaded || isAudioLoaded) return;
|
|
403
|
+
const timer = setTimeout(() => setLegacyFailed(true), LEGACY_REALTIME_DEADLINE_MS);
|
|
404
|
+
return () => clearTimeout(timer);
|
|
405
|
+
}, [isLegacyRealtime, legacyFailed, isVideoLoaded, isAudioLoaded]);
|
|
406
|
+
|
|
366
407
|
// ── WebRTC subscription, v1 (Cloudflare Calls) ─────────────────────────────
|
|
367
408
|
useEffect(() => {
|
|
368
409
|
const config = broadcast.realtimeConfig;
|
|
@@ -413,9 +454,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
413
454
|
};
|
|
414
455
|
|
|
415
456
|
subscribe().catch(() => {
|
|
416
|
-
//
|
|
417
|
-
//
|
|
457
|
+
// A spinner is not an honest report when there is a stream the viewer
|
|
458
|
+
// could be watching. Falling back is.
|
|
418
459
|
isSubscribingRef.current = false;
|
|
460
|
+
setLegacyFailed(true);
|
|
419
461
|
});
|
|
420
462
|
// `sessionApi` is rebuilt each render and is deliberately not a dependency:
|
|
421
463
|
// including it would re-negotiate the peer connection on every render.
|
|
@@ -537,10 +579,45 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
537
579
|
return (
|
|
538
580
|
<div
|
|
539
581
|
style={{
|
|
582
|
+
/**
|
|
583
|
+
* The video dominates, and the chat takes what is left.
|
|
584
|
+
*
|
|
585
|
+
* `flexWrap` wraps rather than shrinking, so the two panels stacked
|
|
586
|
+
* the moment their BASES no longer fit on a line - and with both
|
|
587
|
+
* growing at the same rate, the chat took half of every extra pixel,
|
|
588
|
+
* so the video was never big even side by side. On an ordinary
|
|
589
|
+
* desktop that read as a small video sitting on top of a chat.
|
|
590
|
+
*
|
|
591
|
+
* The bases below stay on one line down to about 800px, and the video
|
|
592
|
+
* grows three times faster than the chat, so the space a wider screen
|
|
593
|
+
* provides goes almost entirely to the picture. Stacking is then what
|
|
594
|
+
* happens on a genuinely narrow screen, which is where it is right.
|
|
595
|
+
*/
|
|
540
596
|
display: "flex",
|
|
541
597
|
flexWrap: "wrap",
|
|
542
598
|
gap: 32,
|
|
543
|
-
padding:
|
|
599
|
+
// No padding at the TOP: the shell and the close button above already
|
|
600
|
+
// put the player well down the page, and on a phone that was most of
|
|
601
|
+
// the first screen spent before any video.
|
|
602
|
+
padding: "0 16px 16px",
|
|
603
|
+
/**
|
|
604
|
+
* FILL the parent rather than being sized by what is inside.
|
|
605
|
+
*
|
|
606
|
+
* Without this the container was shrink-to-fit, so its width came from
|
|
607
|
+
* its content - and the HLS stage puts its player in an absolutely
|
|
608
|
+
* positioned `inset: 0` box (which is what stops the player resizing
|
|
609
|
+
* its own container on a rendition change). Absolutely positioned
|
|
610
|
+
* children contribute NO intrinsic width, so the content measured as
|
|
611
|
+
* almost nothing and the whole player collapsed to about 400px inside a
|
|
612
|
+
* 1300px column, then wrapped.
|
|
613
|
+
*
|
|
614
|
+
* The realtime stages hid it: they render an in-flow `<video>` with a
|
|
615
|
+
* real intrinsic width, so the same container came out wide enough. It
|
|
616
|
+
* looked like "HLS stacks and the SFU does not", and it was neither -
|
|
617
|
+
* it was a container taking its width from whatever happened to be
|
|
618
|
+
* inside it.
|
|
619
|
+
*/
|
|
620
|
+
width: "100%",
|
|
544
621
|
maxWidth: 1700,
|
|
545
622
|
marginInline: "auto",
|
|
546
623
|
flex: 1,
|
|
@@ -548,7 +625,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
548
625
|
fontFamily: theme.fontFamily,
|
|
549
626
|
}}
|
|
550
627
|
>
|
|
551
|
-
<div style={{ flex: "
|
|
628
|
+
<div style={{ flex: "3 1 420px", minWidth: 0, ...panel }}>
|
|
552
629
|
{showMoq && moqStage && moqCredentials && (
|
|
553
630
|
<MoqStageRenderer
|
|
554
631
|
Stage={moqStage}
|
|
@@ -643,7 +720,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
643
720
|
</div>
|
|
644
721
|
)}
|
|
645
722
|
|
|
646
|
-
{isLegacyRealtime && (
|
|
723
|
+
{isLegacyRealtime && !legacyFailed && (
|
|
647
724
|
<div
|
|
648
725
|
ref={videoContainerRef}
|
|
649
726
|
data-testid="broadcast-realtime-stage"
|
|
@@ -815,7 +892,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
815
892
|
</div>
|
|
816
893
|
</div>
|
|
817
894
|
|
|
818
|
-
<div style={{ flex: "1 1
|
|
895
|
+
<div style={{ flex: "1 1 300px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
|
|
819
896
|
<p style={{ fontSize: 18, padding: 16, borderBottom: `1px solid ${theme.colors.primary}30` }}>
|
|
820
897
|
{t("forge.broadcast_player.chat")}
|
|
821
898
|
</p>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* The v1 Cloudflare lane must end at HLS like the other two.
|
|
4
|
+
*
|
|
5
|
+
* `isLegacyRealtime` SUPPRESSED the HLS stage outright, so a broadcast whose
|
|
6
|
+
* Cloudflare room was gone - retired, expired, or never created for a v2
|
|
7
|
+
* broadcast still carrying a stale `realtimeConfig` - showed a spinner and
|
|
8
|
+
* nothing else, for ever, while HLS was being published the whole time.
|
|
9
|
+
* Reported from production as a live broadcast showing nothing on the site.
|
|
10
|
+
*
|
|
11
|
+
* The rule lives in the component (it needs the lane's load state, which this
|
|
12
|
+
* pure chooser has no business knowing), so this pins the SHAPE of it: three
|
|
13
|
+
* lanes, and every one of them has an exit to HLS.
|
|
14
|
+
*/
|
|
15
|
+
describe("every realtime lane has an exit to HLS", () => {
|
|
16
|
+
const showHls = (input: { stage: string; isLegacyRealtime: boolean; legacyFailed: boolean }) =>
|
|
17
|
+
input.stage === "hls" && (!input.isLegacyRealtime || input.legacyFailed);
|
|
18
|
+
|
|
19
|
+
it("keeps HLS hidden while the v1 lane is still trying", () => {
|
|
20
|
+
// Not a regression: mounting an HLS engine beside a working realtime lane
|
|
21
|
+
// makes the viewer download the broadcast twice.
|
|
22
|
+
expect(showHls({ stage: "hls", isLegacyRealtime: true, legacyFailed: false })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("shows HLS once the v1 lane has given up", () => {
|
|
26
|
+
expect(showHls({ stage: "hls", isLegacyRealtime: true, legacyFailed: true })).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("shows HLS normally when there is no v1 lane at all", () => {
|
|
30
|
+
expect(showHls({ stage: "hls", isLegacyRealtime: false, legacyFailed: false })).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { relayIsReachable } from "./relayReachable";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The lane must be able to give up. `<moq-watch>` retries a refused connection
|
|
7
|
+
* for ever and never raises an `error` event, so without this the viewer sits
|
|
8
|
+
* on a dead stage while HLS is being published.
|
|
9
|
+
*/
|
|
10
|
+
describe("probing the MoQ relay before handing a viewer to it", () => {
|
|
11
|
+
it("is reachable when the fingerprint responds", async () => {
|
|
12
|
+
expect(await relayIsReachable("https://relay.example/room/name", vi.fn(async () => new Response("")))).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("is NOT reachable when the connection is refused", async () => {
|
|
16
|
+
// The dev case: no relay running on localhost:4444.
|
|
17
|
+
const refused = vi.fn(async () => {
|
|
18
|
+
throw new TypeError("Failed to fetch");
|
|
19
|
+
});
|
|
20
|
+
expect(await relayIsReachable("http://localhost:4444/room/name", refused)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("asks the relay ORIGIN, not the ticketed path", async () => {
|
|
24
|
+
const fetchImpl = vi.fn(async () => new Response(""));
|
|
25
|
+
await relayIsReachable("https://relay.example/room/name?jwt=abc", fetchImpl);
|
|
26
|
+
expect(fetchImpl.mock.calls[0]?.[0]).toBe("https://relay.example/certificate.sha256");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("gives up rather than hanging when nothing answers", async () => {
|
|
30
|
+
const hangs = vi.fn(
|
|
31
|
+
(_url: string, init?: { signal?: AbortSignal }) =>
|
|
32
|
+
new Promise<Response>((_resolve, reject) => {
|
|
33
|
+
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
expect(await relayIsReachable("https://relay.example/x", hangs as unknown as typeof fetch, 10)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("treats an unparseable URL as unreachable rather than throwing", async () => {
|
|
40
|
+
expect(await relayIsReachable("not a url", vi.fn())).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the MoQ relay actually there, before a viewer is handed to it?
|
|
3
|
+
*
|
|
4
|
+
* `<moq-watch>` owns its own reconnect loop: a refused connection produces a
|
|
5
|
+
* console error and a retry, for ever, and never an `error` event. So the
|
|
6
|
+
* lane's `onFailed` never fires, `chooseBroadcastStage` keeps answering "moq",
|
|
7
|
+
* and the HLS stage is never mounted - the viewer watches nothing while the
|
|
8
|
+
* broadcast is being published the whole time.
|
|
9
|
+
*
|
|
10
|
+
* A blind timeout would be the wrong fix: it cannot tell a slow relay from a
|
|
11
|
+
* dead one and would cut off a stream that was about to work. This asks the
|
|
12
|
+
* question that actually has an answer. The element fetches
|
|
13
|
+
* `/certificate.sha256` from the relay origin before it connects; if that is
|
|
14
|
+
* unreachable, so is the relay, and the lane is over before it began.
|
|
15
|
+
*
|
|
16
|
+
* The common case is not an outage. It is a developer with no relay running
|
|
17
|
+
* locally, where the URL is `http://localhost:4444` and correct.
|
|
18
|
+
*/
|
|
19
|
+
export const RELAY_PROBE_TIMEOUT_MS = 3000;
|
|
20
|
+
|
|
21
|
+
export async function relayIsReachable(
|
|
22
|
+
url: string,
|
|
23
|
+
fetchImpl: typeof fetch = fetch,
|
|
24
|
+
timeoutMs: number = RELAY_PROBE_TIMEOUT_MS,
|
|
25
|
+
): Promise<boolean> {
|
|
26
|
+
let origin: string;
|
|
27
|
+
try {
|
|
28
|
+
origin = new URL("/certificate.sha256", url).toString();
|
|
29
|
+
} catch {
|
|
30
|
+
// An unparseable relay URL is not reachable by any definition.
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
|
+
try {
|
|
37
|
+
// `no-cors` on purpose: the answer wanted is "did the connection happen",
|
|
38
|
+
// not what the body says. An opaque response is a reachable relay, and it
|
|
39
|
+
// keeps this probe from needing CORS the element itself may not need.
|
|
40
|
+
await fetchImpl(origin, { mode: "no-cors", signal: controller.signal });
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|