@tribe-nest/forge 3.41.1 → 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 +2 -2
- package/src/_tests/publishedResolvability.spec.ts +81 -3
- package/src/data/queries/useAuthActions.ts +15 -2
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -2
- package/src/ui/styled/_tests/broadcastStage.spec.ts +41 -0
- package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +124 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +239 -16
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +6 -3
- package/src/ui/styled/broadcast/broadcastStage.spec.ts +32 -0
- package/src/ui/styled/broadcast/broadcastStage.ts +38 -2
- package/src/ui/styled/broadcast/relayReachable.spec.ts +42 -0
- package/src/ui/styled/broadcast/relayReachable.ts +47 -0
- package/src/ui/styled/broadcast/vendor/moq-watch.bundle.d.ts +12 -0
- package/src/ui/styled/broadcast/vendor/moq-watch.bundle.js +549 -0
- package/src/ui/styled/forge-utilities.css +7 -0
|
@@ -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,10 +151,22 @@ export interface BroadcastPlayerProps {
|
|
|
132
151
|
*
|
|
133
152
|
* ## Why the quality picker is not on the new path
|
|
134
153
|
*
|
|
135
|
-
* The plane
|
|
136
|
-
*
|
|
137
|
-
*
|
|
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
|
*/
|
|
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
|
+
|
|
139
170
|
export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
|
|
140
171
|
const t = useForgeT();
|
|
141
172
|
const theme = useForgeTheme();
|
|
@@ -152,6 +183,21 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
152
183
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
153
184
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
154
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);
|
|
155
201
|
const [isAudioLoaded, setIsAudioLoaded] = useState(false);
|
|
156
202
|
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
157
203
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
@@ -178,6 +224,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
178
224
|
const [PlaneStage, setPlaneStage] = useState<ComponentType<BroadcastRealtimeStageProps> | null>(null);
|
|
179
225
|
const [credentialsReady, setCredentialsReady] = useState(false);
|
|
180
226
|
const [credentialsError, setCredentialsError] = useState<BroadcastCredentialsError | null>(null);
|
|
227
|
+
/**
|
|
228
|
+
* The relay lane, as `viewer-token` reported it.
|
|
229
|
+
*
|
|
230
|
+
* Held separately from the plane credentials because the two are independent
|
|
231
|
+
* answers: an instance may offer a relay and no plane, a plane and no relay,
|
|
232
|
+
* or both. `planeAvailable` reads the plane's half off the same response.
|
|
233
|
+
*/
|
|
234
|
+
const [moqStage, setMoqStage] = useState<ComponentType<BroadcastMoqStageProps> | null>(null);
|
|
235
|
+
const [moqCredentials, setMoqCredentials] = useState<BroadcastMoqCredentials | null>(null);
|
|
236
|
+
const [moqFailed, setMoqFailed] = useState(false);
|
|
237
|
+
const [planeAvailable, setPlaneAvailable] = useState(true);
|
|
181
238
|
const [roomState, setRoomState] = useState<BroadcastRoomState | undefined>(undefined);
|
|
182
239
|
/** The ticket the probe already minted, spent on the SDK's FIRST attempt. */
|
|
183
240
|
const primedCredentials = useRef<BroadcastViewerCredentials | null>(null);
|
|
@@ -223,6 +280,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
223
280
|
if (cancelled) return;
|
|
224
281
|
primedCredentials.current = credentials;
|
|
225
282
|
setPlaneStage(() => module.BroadcastRealtimeStage);
|
|
283
|
+
// A response with no `mediaUrl` is a relay-only instance. Recording it
|
|
284
|
+
// is what stops the chooser falling to a plane room that is not there
|
|
285
|
+
// if the relay lane later gives up.
|
|
286
|
+
setPlaneAvailable(Boolean((credentials as { mediaUrl?: string }).mediaUrl));
|
|
287
|
+
const moq = (credentials as { moq?: BroadcastMoqCredentials }).moq ?? null;
|
|
288
|
+
setMoqCredentials(moq);
|
|
289
|
+
if (moq) {
|
|
290
|
+
import("./BroadcastMoqStage")
|
|
291
|
+
.then((moqModule) => {
|
|
292
|
+
if (cancelled) return;
|
|
293
|
+
setMoqStage(() => moqModule.BroadcastMoqStage);
|
|
294
|
+
})
|
|
295
|
+
.catch(() => {
|
|
296
|
+
if (cancelled) return;
|
|
297
|
+
// The relay chunk did not load. The lane is over before it began.
|
|
298
|
+
setMoqFailed(true);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
226
301
|
setCredentialsReady(true);
|
|
227
302
|
})
|
|
228
303
|
.catch((error: unknown) => {
|
|
@@ -266,17 +341,33 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
266
341
|
*/
|
|
267
342
|
const handleRoomState = useCallback((next: BroadcastRoomState) => setRoomState(next), []);
|
|
268
343
|
|
|
344
|
+
/**
|
|
345
|
+
* The relay lane gave up. Never cleared back to false, for the same reason
|
|
346
|
+
* `roomState` is never cleared: the failure is why we left, so forgetting it
|
|
347
|
+
* would choose the relay again, mount it again, and watch it fail again.
|
|
348
|
+
*/
|
|
349
|
+
const handleMoqFailed = useCallback(() => setMoqFailed(true), []);
|
|
350
|
+
|
|
269
351
|
const stageInput = useMemo(
|
|
270
|
-
() => ({
|
|
271
|
-
|
|
352
|
+
() => ({
|
|
353
|
+
realtimeAvailable,
|
|
354
|
+
credentialsReady,
|
|
355
|
+
credentialsError,
|
|
356
|
+
roomState,
|
|
357
|
+
moqAvailable: Boolean(moqCredentials && moqStage),
|
|
358
|
+
moqFailed,
|
|
359
|
+
planeAvailable,
|
|
360
|
+
}),
|
|
361
|
+
[realtimeAvailable, credentialsReady, credentialsError, roomState, moqCredentials, moqStage, moqFailed, planeAvailable],
|
|
272
362
|
);
|
|
273
363
|
const stage = chooseBroadcastStage(stageInput);
|
|
364
|
+
const showMoq = stage === "moq" && !!moqStage && !!moqCredentials;
|
|
274
365
|
const showPlane = stage === "realtime" && !!PlaneStage;
|
|
275
366
|
// Nothing is mounted while the ticket is in flight. `react-player` builds an
|
|
276
367
|
// hls.js instance on mount and starts fetching immediately, so rendering the
|
|
277
368
|
// HLS stage "meanwhile" made every realtime viewer download the broadcast a
|
|
278
369
|
// second time, over the CDN, for a stage that was about to be replaced.
|
|
279
|
-
const showHls = stage === "hls" && !isLegacyRealtime;
|
|
370
|
+
const showHls = stage === "hls" && (!isLegacyRealtime || legacyFailed);
|
|
280
371
|
const fellBack = broadcastFellBack(stageInput);
|
|
281
372
|
|
|
282
373
|
const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
|
|
@@ -295,6 +386,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
295
386
|
});
|
|
296
387
|
}, [comments]);
|
|
297
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
|
+
|
|
298
407
|
// ── WebRTC subscription, v1 (Cloudflare Calls) ─────────────────────────────
|
|
299
408
|
useEffect(() => {
|
|
300
409
|
const config = broadcast.realtimeConfig;
|
|
@@ -345,9 +454,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
345
454
|
};
|
|
346
455
|
|
|
347
456
|
subscribe().catch(() => {
|
|
348
|
-
//
|
|
349
|
-
//
|
|
457
|
+
// A spinner is not an honest report when there is a stream the viewer
|
|
458
|
+
// could be watching. Falling back is.
|
|
350
459
|
isSubscribingRef.current = false;
|
|
460
|
+
setLegacyFailed(true);
|
|
351
461
|
});
|
|
352
462
|
// `sessionApi` is rebuilt each render and is deliberately not a dependency:
|
|
353
463
|
// including it would re-negotiate the peer connection on every render.
|
|
@@ -469,10 +579,45 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
469
579
|
return (
|
|
470
580
|
<div
|
|
471
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
|
+
*/
|
|
472
596
|
display: "flex",
|
|
473
597
|
flexWrap: "wrap",
|
|
474
598
|
gap: 32,
|
|
475
|
-
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%",
|
|
476
621
|
maxWidth: 1700,
|
|
477
622
|
marginInline: "auto",
|
|
478
623
|
flex: 1,
|
|
@@ -480,7 +625,20 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
480
625
|
fontFamily: theme.fontFamily,
|
|
481
626
|
}}
|
|
482
627
|
>
|
|
483
|
-
<div style={{ flex: "
|
|
628
|
+
<div style={{ flex: "3 1 420px", minWidth: 0, ...panel }}>
|
|
629
|
+
{showMoq && moqStage && moqCredentials && (
|
|
630
|
+
<MoqStageRenderer
|
|
631
|
+
Stage={moqStage}
|
|
632
|
+
url={moqCredentials.url}
|
|
633
|
+
name={moqCredentials.name}
|
|
634
|
+
// Not muted: this is the broadcast the viewer came for, and the
|
|
635
|
+
// plane stage does not mute either. The element paints video to a
|
|
636
|
+
// canvas independently of its audio emitter, so a browser that
|
|
637
|
+
// blocks autoplay audio still shows a picture rather than nothing.
|
|
638
|
+
muted={false}
|
|
639
|
+
onFailed={handleMoqFailed}
|
|
640
|
+
/>
|
|
641
|
+
)}
|
|
484
642
|
{showPlane && PlaneStage && <PlaneStage getCredentials={getCredentials} onRoomState={handleRoomState} />}
|
|
485
643
|
|
|
486
644
|
{stage === "pending" && (
|
|
@@ -490,7 +648,26 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
490
648
|
)}
|
|
491
649
|
|
|
492
650
|
{showHls && (
|
|
493
|
-
|
|
651
|
+
/*
|
|
652
|
+
The ratio box, and INSIDE it an absolutely filled box that the
|
|
653
|
+
player cannot resize.
|
|
654
|
+
|
|
655
|
+
`renderPlayer` is host-supplied (the starter passes `react-player`),
|
|
656
|
+
so this cannot dictate how the player sizes itself - and the
|
|
657
|
+
conventional `height: "100%"` a player is given resolves against a
|
|
658
|
+
parent whose height comes from `aspect-ratio`, which is not reliably
|
|
659
|
+
a definite height. The player then falls back to the stream's
|
|
660
|
+
intrinsic size and the panel jumps every time HLS changes rendition.
|
|
661
|
+
|
|
662
|
+
An absolutely positioned `inset: 0` box HAS a definite height, so a
|
|
663
|
+
child asking for 100% gets a real answer, and nothing the child does
|
|
664
|
+
can change the box it is in.
|
|
665
|
+
*/
|
|
666
|
+
<div
|
|
667
|
+
data-testid="broadcast-hls-stage"
|
|
668
|
+
style={{ aspectRatio: "16/9", position: "relative", lineHeight: 0 }}
|
|
669
|
+
>
|
|
670
|
+
<div style={{ position: "absolute", inset: 0 }}>
|
|
494
671
|
{renderPlayer ? (
|
|
495
672
|
renderPlayer({ src: broadcast.liveUrl })
|
|
496
673
|
) : (
|
|
@@ -499,9 +676,20 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
499
676
|
controls
|
|
500
677
|
autoPlay
|
|
501
678
|
playsInline
|
|
502
|
-
|
|
679
|
+
// Same reasoning as the realtime stage below: HLS renditions
|
|
680
|
+
// change resolution mid-stream too, and `height: 100%` off an
|
|
681
|
+
// `aspect-ratio` parent leaves the box following the source.
|
|
682
|
+
style={{
|
|
683
|
+
display: "block",
|
|
684
|
+
width: "100%",
|
|
685
|
+
height: "auto",
|
|
686
|
+
aspectRatio: "16 / 9",
|
|
687
|
+
objectFit: "contain",
|
|
688
|
+
background: "#000",
|
|
689
|
+
}}
|
|
503
690
|
/>
|
|
504
691
|
)}
|
|
692
|
+
</div>
|
|
505
693
|
</div>
|
|
506
694
|
)}
|
|
507
695
|
|
|
@@ -532,16 +720,51 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
532
720
|
</div>
|
|
533
721
|
)}
|
|
534
722
|
|
|
535
|
-
{isLegacyRealtime && (
|
|
723
|
+
{isLegacyRealtime && !legacyFailed && (
|
|
536
724
|
<div
|
|
537
725
|
ref={videoContainerRef}
|
|
538
726
|
data-testid="broadcast-realtime-stage"
|
|
539
727
|
onClick={togglePlay}
|
|
540
728
|
onMouseEnter={() => setShowControls(true)}
|
|
541
729
|
onMouseLeave={() => setShowControls(false)}
|
|
542
|
-
style={{
|
|
730
|
+
style={{
|
|
731
|
+
aspectRatio: "16/9",
|
|
732
|
+
position: "relative",
|
|
733
|
+
background: "#000",
|
|
734
|
+
cursor: "pointer",
|
|
735
|
+
lineHeight: 0,
|
|
736
|
+
}}
|
|
543
737
|
>
|
|
544
|
-
|
|
738
|
+
{/*
|
|
739
|
+
The RATIO is on the video element itself, and `height` is `auto`.
|
|
740
|
+
|
|
741
|
+
This is the same fix `BroadcastRealtimeStage` carries, and this
|
|
742
|
+
path is where it was actually needed: it is the one with a quality
|
|
743
|
+
picker, so the incoming resolution changes whenever the viewer
|
|
744
|
+
chooses a layer (or the auto policy chooses one for them).
|
|
745
|
+
|
|
746
|
+
`height: 100%` did not hold the box still. A percentage height
|
|
747
|
+
needs a definite parent height, and one derived from the parent's
|
|
748
|
+
own `aspect-ratio` is not reliably that, so the replaced element
|
|
749
|
+
fell back to the track's INTRINSIC height and the whole panel
|
|
750
|
+
jumped on every quality change. `aspect-ratio` with `height: auto`
|
|
751
|
+
overrides the intrinsic ratio outright: the box is 16/9 of
|
|
752
|
+
whatever width it is given, whatever arrives on the wire.
|
|
753
|
+
|
|
754
|
+
`object-fit: contain` then letterboxes a source that is not 16/9
|
|
755
|
+
inside that fixed box rather than resizing the box to suit it.
|
|
756
|
+
*/}
|
|
757
|
+
<video
|
|
758
|
+
ref={videoRef}
|
|
759
|
+
playsInline
|
|
760
|
+
style={{
|
|
761
|
+
display: "block",
|
|
762
|
+
width: "100%",
|
|
763
|
+
height: "auto",
|
|
764
|
+
aspectRatio: "16 / 9",
|
|
765
|
+
objectFit: "contain",
|
|
766
|
+
}}
|
|
767
|
+
/>
|
|
545
768
|
<audio ref={audioRef} />
|
|
546
769
|
|
|
547
770
|
{(!isAudioLoaded || !isVideoLoaded) && (
|
|
@@ -669,7 +892,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
669
892
|
</div>
|
|
670
893
|
</div>
|
|
671
894
|
|
|
672
|
-
<div style={{ flex: "1 1
|
|
895
|
+
<div style={{ flex: "1 1 300px", maxWidth: 400, height: 600, position: "relative", overflow: "hidden", ...panel }}>
|
|
673
896
|
<p style={{ fontSize: 18, padding: 16, borderBottom: `1px solid ${theme.colors.primary}30` }}>
|
|
674
897
|
{t("forge.broadcast_player.chat")}
|
|
675
898
|
</p>
|
|
@@ -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
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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 = {
|
|
@@ -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
|
+
});
|
|
@@ -28,7 +28,16 @@ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from
|
|
|
28
28
|
* picture.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* `moq` is a relay, `realtime` is the media plane, and `hls` is the stream
|
|
33
|
+
* everybody can always watch.
|
|
34
|
+
*
|
|
35
|
+
* `moq` outranks `realtime` because the two show the same picture at the same
|
|
36
|
+
* latency and cost very different amounts: a relay viewer is one more
|
|
37
|
+
* subscriber on a cached object, and a plane viewer is a WebRTC consumer with
|
|
38
|
+
* its own SRTP context and its own bandwidth estimate, capped at
|
|
39
|
+
* `STREAM_V2_REALTIME_VIEWER_CAP` because the node runs out.
|
|
40
|
+
*/
|
|
32
41
|
/**
|
|
33
42
|
* Which stage the player shows.
|
|
34
43
|
*
|
|
@@ -37,7 +46,7 @@ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from
|
|
|
37
46
|
* was showing HLS meanwhile, and an HLS engine starts fetching the moment it
|
|
38
47
|
* mounts, so a realtime viewer pulled segments they would never watch.
|
|
39
48
|
*/
|
|
40
|
-
export type BroadcastStage = "realtime" | "hls" | "pending";
|
|
49
|
+
export type BroadcastStage = "moq" | "realtime" | "hls" | "pending";
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* The viewer-token endpoint said no.
|
|
@@ -78,6 +87,25 @@ export type BroadcastStageInput = {
|
|
|
78
87
|
/** The refusal, when the fetch failed. `null` while it has not. */
|
|
79
88
|
credentialsError: BroadcastCredentialsError | null;
|
|
80
89
|
roomState?: BroadcastRoomState;
|
|
90
|
+
/** The token endpoint returned a relay ticket for this viewer. */
|
|
91
|
+
moqAvailable?: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The relay lane was tried and is over.
|
|
94
|
+
*
|
|
95
|
+
* Separate from `moqAvailable` for the same reason `realtimeIsOver` is
|
|
96
|
+
* separate from `credentialsReady`: a ticket that exists is not a lane that
|
|
97
|
+
* is working, and the fallback has to be able to happen after a connection
|
|
98
|
+
* that started fine.
|
|
99
|
+
*/
|
|
100
|
+
moqFailed?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Is the plane lane offered at all?
|
|
103
|
+
*
|
|
104
|
+
* `false` on an instance that runs relays and no media plane, which is what
|
|
105
|
+
* `credentials: null` from `viewer-token` means. Without it the chooser would
|
|
106
|
+
* fall to `realtime` after a relay failure and mount a room that is not there.
|
|
107
|
+
*/
|
|
108
|
+
planeAvailable?: boolean;
|
|
81
109
|
};
|
|
82
110
|
|
|
83
111
|
/**
|
|
@@ -115,7 +143,12 @@ export function chooseBroadcastStage(input: BroadcastStageInput): BroadcastStage
|
|
|
115
143
|
// about to be replaced, so a realtime viewer downloaded the broadcast twice.
|
|
116
144
|
// Nothing is drawn until the answer is known.
|
|
117
145
|
if (!input.credentialsReady) return "pending";
|
|
146
|
+
// The relay first, and only while it is actually working.
|
|
147
|
+
if (input.moqAvailable && !input.moqFailed) return "moq";
|
|
118
148
|
if (realtimeIsOver(input.roomState)) return "hls";
|
|
149
|
+
// The relay failed and there is no plane behind it. `planeAvailable` defaults
|
|
150
|
+
// to true so every existing caller keeps its behaviour unchanged.
|
|
151
|
+
if (input.planeAvailable === false) return "hls";
|
|
119
152
|
return "realtime";
|
|
120
153
|
}
|
|
121
154
|
|
|
@@ -135,6 +168,9 @@ export function broadcastFellBack(input: BroadcastStageInput): boolean {
|
|
|
135
168
|
if (!input.realtimeAvailable) return false;
|
|
136
169
|
if (input.credentialsError) return true;
|
|
137
170
|
if (!input.credentialsReady) return false;
|
|
171
|
+
// A working relay is not a fallback, it is the best lane there is.
|
|
172
|
+
if (input.moqAvailable && !input.moqFailed) return false;
|
|
173
|
+
if (input.moqAvailable && input.moqFailed && input.planeAvailable === false) return true;
|
|
138
174
|
return realtimeIsOver(input.roomState);
|
|
139
175
|
}
|
|
140
176
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@moq/watch`, vendored as a self-contained ESM bundle.
|
|
3
|
+
*
|
|
4
|
+
* Side-effect only: importing it registers the `<moq-watch>` custom element.
|
|
5
|
+
* There is nothing to import by name, which is why this declares an empty
|
|
6
|
+
* module rather than a surface.
|
|
7
|
+
*
|
|
8
|
+
* Built by `scripts/build-moq-player.mjs`; see its header for why this is an
|
|
9
|
+
* asset rather than a dependency.
|
|
10
|
+
*/
|
|
11
|
+
declare const _default: void;
|
|
12
|
+
export default _default;
|