@tribe-nest/forge 3.38.0 → 3.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/useBroadcasts.ts +62 -7
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/types/models.ts +18 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +6 -2
- package/src/ui/media/_tests/CallStage.spec.tsx +1 -0
- package/src/ui/styled/_tests/broadcastStage.spec.ts +316 -0
- package/src/ui/styled/_tests/uiEntryServerSafety.spec.ts +127 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +233 -14
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +260 -0
- package/src/ui/styled/broadcast/broadcastStage.ts +189 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from "@tribe-nest/media-client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Which stage a live broadcast plays on, as pure functions.
|
|
5
|
+
*
|
|
6
|
+
* A v2 broadcast can be watched two ways. The platform's own media plane
|
|
7
|
+
* carries it over WebRTC in well under a second, and HLS carries it to
|
|
8
|
+
* everybody else six to ten seconds behind. The plane is the better picture and
|
|
9
|
+
* it is also the one with a hard cap on it (`STREAM_V2_REALTIME_VIEWER_CAP`,
|
|
10
|
+
* 100 by default), so being turned away from it is an ORDINARY outcome rather
|
|
11
|
+
* than an error, and the viewer who is turned away must simply end up watching
|
|
12
|
+
* the stream.
|
|
13
|
+
*
|
|
14
|
+
* That is the whole reason this is a pure function in its own file. The choice
|
|
15
|
+
* is made from four independent signals arriving at four different times (what
|
|
16
|
+
* the broadcast advertises, what the token endpoint answered, what the SDK is
|
|
17
|
+
* doing, and whether the room is still open), and a rule assembled inline out
|
|
18
|
+
* of four `if`s inside a component is a rule nobody can test and everybody
|
|
19
|
+
* edits. `callState.ts` splits the call screen's decisions out for the same
|
|
20
|
+
* reason.
|
|
21
|
+
*
|
|
22
|
+
* ## The bias, stated once
|
|
23
|
+
*
|
|
24
|
+
* Every uncertain state resolves to `hls`. A viewer on HLS is watching the
|
|
25
|
+
* broadcast a few seconds late; a viewer left on a realtime stage that is not
|
|
26
|
+
* going to connect is watching a black rectangle. Those are not comparable
|
|
27
|
+
* failures, so the ambiguity is spent in the direction that always shows a
|
|
28
|
+
* picture.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** `realtime` is the media plane. `hls` is the stream everybody can always watch. */
|
|
32
|
+
/**
|
|
33
|
+
* Which stage the player shows.
|
|
34
|
+
*
|
|
35
|
+
* `pending` is the state between "this broadcast offers realtime" and knowing
|
|
36
|
+
* whether this viewer gets it. It draws nothing on purpose: the alternative
|
|
37
|
+
* was showing HLS meanwhile, and an HLS engine starts fetching the moment it
|
|
38
|
+
* mounts, so a realtime viewer pulled segments they would never watch.
|
|
39
|
+
*/
|
|
40
|
+
export type BroadcastStage = "realtime" | "hls" | "pending";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The viewer-token endpoint said no.
|
|
44
|
+
*
|
|
45
|
+
* `409` is the designed refusal (the room is full, realtime is off, or nothing
|
|
46
|
+
* is live) and it is not special-cased below: a refusal is a refusal, and a
|
|
47
|
+
* network failure with no status at all falls back exactly the same way. The
|
|
48
|
+
* status is carried so a caller can log which one happened, never so this
|
|
49
|
+
* function can treat one as recoverable.
|
|
50
|
+
*/
|
|
51
|
+
export type BroadcastCredentialsError = {
|
|
52
|
+
/** The HTTP status when the server answered at all. Absent on a network failure. */
|
|
53
|
+
status?: number;
|
|
54
|
+
message?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** What the SDK says about the live room. Absent until the provider is mounted. */
|
|
58
|
+
export type BroadcastRoomState = {
|
|
59
|
+
connectionState: ConnectionState;
|
|
60
|
+
/** The ROOM's phase. `closed` means the node said so, on a socket still open. */
|
|
61
|
+
phase: RoomState["phase"];
|
|
62
|
+
/**
|
|
63
|
+
* Is another attempt actually booked?
|
|
64
|
+
*
|
|
65
|
+
* `connectionState` is `reconnecting` both while the SDK is coming back and
|
|
66
|
+
* after its policy has given up, so without this a broadcast whose room died
|
|
67
|
+
* would sit on a frozen frame for ever rather than falling back.
|
|
68
|
+
*/
|
|
69
|
+
recovering: boolean;
|
|
70
|
+
error?: DisconnectCause;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type BroadcastStageInput = {
|
|
74
|
+
/** `broadcast.realtime.available`, as the public read reported it. */
|
|
75
|
+
realtimeAvailable: boolean;
|
|
76
|
+
/** A viewer credential has been minted. Nothing is mounted before it has. */
|
|
77
|
+
credentialsReady: boolean;
|
|
78
|
+
/** The refusal, when the fetch failed. `null` while it has not. */
|
|
79
|
+
credentialsError: BroadcastCredentialsError | null;
|
|
80
|
+
roomState?: BroadcastRoomState;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Is the realtime attempt over, as opposed to merely in progress?
|
|
85
|
+
*
|
|
86
|
+
* A drop that the SDK is recovering from is NOT over: it holds the last frame
|
|
87
|
+
* for a second or two and comes back, and tearing the room down to swap in an
|
|
88
|
+
* HLS player would turn a blink into a restart. A drain is the same, as long as
|
|
89
|
+
* the move is really happening. Everything else on this list is finished:
|
|
90
|
+
* refused, the room closed, the socket closed for good, or the reconnect policy
|
|
91
|
+
* exhausted with nothing booked.
|
|
92
|
+
*/
|
|
93
|
+
function realtimeIsOver(roomState: BroadcastRoomState | undefined): boolean {
|
|
94
|
+
if (!roomState) return false;
|
|
95
|
+
if (roomState.phase === "closed") return true;
|
|
96
|
+
if (roomState.connectionState === "closed") return true;
|
|
97
|
+
if (roomState.error?.type === "refused") return true;
|
|
98
|
+
if (roomState.error?.type === "room_closed") return true;
|
|
99
|
+
return roomState.connectionState === "reconnecting" && !roomState.recovering;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The stage to render right now.
|
|
104
|
+
*
|
|
105
|
+
* Read in order, and the order is the safety property: nothing reaches the
|
|
106
|
+
* realtime branch until the broadcast has offered it, a credential has actually
|
|
107
|
+
* been minted, and the room is still alive.
|
|
108
|
+
*/
|
|
109
|
+
export function chooseBroadcastStage(input: BroadcastStageInput): BroadcastStage {
|
|
110
|
+
if (!input.realtimeAvailable) return "hls";
|
|
111
|
+
if (input.credentialsError) return "hls";
|
|
112
|
+
// PENDING, not "hls". Answering "hls" here mounted the HLS engine for the
|
|
113
|
+
// moment the ticket was in flight, and an HLS engine does not idle: it
|
|
114
|
+
// fetched the playlist and started pulling segments for a stage that was
|
|
115
|
+
// about to be replaced, so a realtime viewer downloaded the broadcast twice.
|
|
116
|
+
// Nothing is drawn until the answer is known.
|
|
117
|
+
if (!input.credentialsReady) return "pending";
|
|
118
|
+
if (realtimeIsOver(input.roomState)) return "hls";
|
|
119
|
+
return "realtime";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Did this viewer come DOWN to HLS, or were they always going to be here?
|
|
124
|
+
*
|
|
125
|
+
* The distinction is the whole content of the line the player shows. A
|
|
126
|
+
* broadcast that never offered realtime has nothing to explain, and a page that
|
|
127
|
+
* says "watching the standard stream" on every ordinary stream is a page that
|
|
128
|
+
* has taught its viewers to ignore the one time it matters.
|
|
129
|
+
*
|
|
130
|
+
* A token fetch still in flight is deliberately NOT a fallback. It resolves in
|
|
131
|
+
* a moment, and announcing a fallback that is about to be withdrawn is a line
|
|
132
|
+
* that flickers on every single load.
|
|
133
|
+
*/
|
|
134
|
+
export function broadcastFellBack(input: BroadcastStageInput): boolean {
|
|
135
|
+
if (!input.realtimeAvailable) return false;
|
|
136
|
+
if (input.credentialsError) return true;
|
|
137
|
+
if (!input.credentialsReady) return false;
|
|
138
|
+
return realtimeIsOver(input.roomState);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The one program feed, split into the elements that carry it. */
|
|
142
|
+
export type BroadcastProgramTracks = {
|
|
143
|
+
/** Attach with `useRemoteTrack`. Absent means no video has arrived yet. */
|
|
144
|
+
videoProducerId?: string;
|
|
145
|
+
/** The publisher paused it at source. Their choice, not a failure. */
|
|
146
|
+
videoPaused: boolean;
|
|
147
|
+
/** The one program soundtrack. An array because it may legitimately be empty. */
|
|
148
|
+
audioProducerIds: readonly string[];
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* What to render out of the producers this viewer may actually receive.
|
|
153
|
+
*
|
|
154
|
+
* The live room holds exactly one publisher, `program:<broadcastId>`, with one
|
|
155
|
+
* video producer and one audio producer, and a viewer token's subscribe rule
|
|
156
|
+
* names that identity and nothing else. So there is no filtering to do here and
|
|
157
|
+
* deliberately none attempted: re-deriving the identity in the browser would
|
|
158
|
+
* give a second answer to a question the node has already answered, and the
|
|
159
|
+
* wrong answer is a black stage on a broadcast that is playing perfectly.
|
|
160
|
+
*
|
|
161
|
+
* The FIRST video is taken rather than the last. A studio that briefly
|
|
162
|
+
* republishes leaves two announced for a frame or two, and picking the newer
|
|
163
|
+
* one would swap the element's stream under a viewer mid-sentence.
|
|
164
|
+
*
|
|
165
|
+
* Audio is returned as a list rather than as one id because the cost of being
|
|
166
|
+
* wrong is asymmetric: a spare `<audio>` with no track attached does nothing at
|
|
167
|
+
* all, and a missed one is a broadcast with no sound.
|
|
168
|
+
*/
|
|
169
|
+
export function programTracks(producers: readonly ProducerEntry[]): BroadcastProgramTracks {
|
|
170
|
+
const video = producers.find((producer) => producer.kind === "video");
|
|
171
|
+
const audio = producers.filter((producer) => producer.kind === "audio");
|
|
172
|
+
return {
|
|
173
|
+
...(video ? { videoProducerId: video.producerId } : {}),
|
|
174
|
+
videoPaused: video?.paused ?? false,
|
|
175
|
+
// ONE, deliberately, not every audio producer in the room.
|
|
176
|
+
//
|
|
177
|
+
// A broadcast has a single soundtrack: the studio's mix, published once by
|
|
178
|
+
// the program feed. If the room ever holds two (a republish after a
|
|
179
|
+
// reconnect racing the old producer's teardown), playing both is never the
|
|
180
|
+
// right answer, so the newer publication wins and the stale one is dropped
|
|
181
|
+
// rather than mixed in.
|
|
182
|
+
//
|
|
183
|
+
// This is a guard, not the fix for the doubled audio that was reported
|
|
184
|
+
// during the build: THAT was the player mounting the HLS stage while the
|
|
185
|
+
// viewer ticket was still in flight, so the same broadcast arrived twice,
|
|
186
|
+
// seconds apart, over two transports. See `chooseBroadcastStage`.
|
|
187
|
+
audioProducerIds: audio.length > 0 ? [audio[audio.length - 1]!.producerId] : [],
|
|
188
|
+
};
|
|
189
|
+
}
|