@tribe-nest/forge 3.38.0 → 3.41.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.
@@ -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;
@@ -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
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Poll tallies as the studio, the stage overlay and the player all render them.
3
+ *
4
+ * One function because three surfaces show the same numbers, and three
5
+ * roundings of the same votes would disagree with each other on screen.
6
+ */
7
+
8
+ export type PollLike = {
9
+ totalVotes: number;
10
+ options: Array<{ id: string; label: string; voteCount: number }>;
11
+ };
12
+
13
+ export type PollResult = {
14
+ id: string;
15
+ label: string;
16
+ voteCount: number;
17
+ /** 0..100, integer. */
18
+ percent: number;
19
+ };
20
+
21
+ /**
22
+ * Percentages that add up to 100.
23
+ *
24
+ * Rounding each share on its own is what usually goes wrong: three options on
25
+ * one vote each show as 33% three times and a viewer reads 99, and 1/3 + 2/3
26
+ * shows as 33 + 67 only by luck. This uses largest remainder, so the displayed
27
+ * numbers sum to exactly 100 whenever anybody has voted at all.
28
+ *
29
+ * With no votes every option is 0, NOT an even split. A poll that has just
30
+ * opened showing "50% / 50%" claims two votes that were never cast.
31
+ */
32
+ export function pollPercentages(poll: PollLike): PollResult[] {
33
+ const options = poll.options ?? [];
34
+ const total = options.reduce((sum, option) => sum + option.voteCount, 0);
35
+ if (total <= 0) {
36
+ return options.map((option) => ({ ...option, percent: 0 }));
37
+ }
38
+
39
+ const exact = options.map((option) => {
40
+ const share = (option.voteCount / total) * 100;
41
+ return { option, floor: Math.floor(share), remainder: share - Math.floor(share) };
42
+ });
43
+
44
+ let left = 100 - exact.reduce((sum, entry) => sum + entry.floor, 0);
45
+ // Biggest remainder first; a tie goes to the option with more votes, then to
46
+ // the one listed first, so the same tally always renders the same way.
47
+ const order = [...exact].sort(
48
+ (a, b) =>
49
+ b.remainder - a.remainder ||
50
+ b.option.voteCount - a.option.voteCount ||
51
+ options.indexOf(a.option) - options.indexOf(b.option),
52
+ );
53
+ const bonus = new Set<string>();
54
+ for (const entry of order) {
55
+ if (left <= 0) break;
56
+ bonus.add(entry.option.id);
57
+ left--;
58
+ }
59
+
60
+ return exact.map(({ option, floor }) => ({
61
+ id: option.id,
62
+ label: option.label,
63
+ voteCount: option.voteCount,
64
+ percent: floor + (bonus.has(option.id) ? 1 : 0),
65
+ }));
66
+ }