@tribe-nest/forge 3.39.0 → 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 +2 -2
- package/src/_tests/publishedResolvability.spec.ts +81 -3
- package/src/data/queries/useAuthActions.ts +15 -2
- package/src/data/queries/useBroadcasts.ts +60 -0
- package/src/i18n/de.json +5 -0
- package/src/i18n/en.json +5 -0
- package/src/ui/headless/broadcast/useBroadcastPoll.ts +225 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +12 -2
- package/src/ui/styled/_tests/broadcastPollVote.spec.ts +127 -0
- package/src/ui/styled/_tests/broadcastStage.spec.ts +41 -0
- package/src/ui/styled/_tests/pollResults.spec.ts +66 -0
- package/src/ui/styled/broadcast/BroadcastMoqStage.tsx +124 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +178 -10
- package/src/ui/styled/broadcast/BroadcastPollPanel.tsx +124 -0
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +6 -3
- package/src/ui/styled/broadcast/broadcastStage.ts +38 -2
- package/src/ui/styled/broadcast/pollResults.ts +66 -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
|
@@ -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,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { pollPercentages, type PollLike } from "../broadcast/pollResults";
|
|
4
|
+
|
|
5
|
+
const poll = (counts: number[]): PollLike => ({
|
|
6
|
+
totalVotes: counts.reduce((a, b) => a + b, 0),
|
|
7
|
+
options: counts.map((voteCount, i) => ({ id: `o${i}`, label: `Option ${i}`, voteCount })),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const percents = (counts: number[]) => pollPercentages(poll(counts)).map((r) => r.percent);
|
|
11
|
+
|
|
12
|
+
describe("pollPercentages", () => {
|
|
13
|
+
it("shows nothing rather than an even split before anybody votes", () => {
|
|
14
|
+
// "50% / 50%" on an untouched poll claims two votes that were never cast.
|
|
15
|
+
expect(percents([0, 0])).toEqual([0, 0]);
|
|
16
|
+
expect(percents([0, 0, 0])).toEqual([0, 0, 0]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("adds up to 100 where naive rounding shows 99", () => {
|
|
20
|
+
// One vote each on three options: 33.33 floored three times is 99.
|
|
21
|
+
expect(percents([1, 1, 1]).reduce((a, b) => a + b, 0)).toBe(100);
|
|
22
|
+
expect(percents([1, 2]).reduce((a, b) => a + b, 0)).toBe(100);
|
|
23
|
+
expect(percents([1, 1, 1, 1, 1, 1]).reduce((a, b) => a + b, 0)).toBe(100);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("gives the spare point to the biggest remainder", () => {
|
|
27
|
+
// 1/3 = 33.33 (remainder .33), 2/3 = 66.67 (remainder .67).
|
|
28
|
+
expect(percents([1, 2])).toEqual([33, 67]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("is exact when it can be", () => {
|
|
32
|
+
expect(percents([1, 1])).toEqual([50, 50]);
|
|
33
|
+
expect(percents([1, 3])).toEqual([25, 75]);
|
|
34
|
+
expect(percents([7, 3])).toEqual([70, 30]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("gives a unanimous poll all of it", () => {
|
|
38
|
+
expect(percents([5, 0, 0])).toEqual([100, 0, 0]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("breaks a remainder tie the same way every time", () => {
|
|
42
|
+
// Two options tied on both share and votes: the earlier one wins, so the
|
|
43
|
+
// overlay does not swap them between renders of an unchanged tally.
|
|
44
|
+
const first = percents([1, 1, 1]);
|
|
45
|
+
const second = percents([1, 1, 1]);
|
|
46
|
+
expect(first).toEqual(second);
|
|
47
|
+
expect(first.reduce((a, b) => a + b, 0)).toBe(100);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps the labels and the raw counts, which are shown beside the bar", () => {
|
|
51
|
+
const results = pollPercentages(poll([3, 1]));
|
|
52
|
+
expect(results.map((r) => r.label)).toEqual(["Option 0", "Option 1"]);
|
|
53
|
+
expect(results.map((r) => r.voteCount)).toEqual([3, 1]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("survives a poll with no options at all", () => {
|
|
57
|
+
expect(pollPercentages({ totalVotes: 0, options: [] })).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("holds up on a large tally", () => {
|
|
61
|
+
const counts = [4321, 987, 65, 4];
|
|
62
|
+
const result = percents(counts);
|
|
63
|
+
expect(result.reduce((a, b) => a + b, 0)).toBe(100);
|
|
64
|
+
expect(result[0]).toBeGreaterThan(result[1]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -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,13 +28,40 @@ 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";
|
|
32
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
|
+
}
|
|
51
|
+
import { BroadcastPollPanel } from "./BroadcastPollPanel";
|
|
52
|
+
import { useBroadcastPoll } from "../../headless/broadcast/useBroadcastPoll";
|
|
53
|
+
import type { BroadcastPoll } from "../../../data/queries/useBroadcasts";
|
|
54
|
+
|
|
33
55
|
/** The socket messages a watching browser sends and receives. */
|
|
34
56
|
const SocketEvent = {
|
|
35
57
|
BroadcastChatMessage: "broadcastChatMessage",
|
|
36
58
|
BroadcastJoin: "broadcastJoin",
|
|
37
59
|
BroadcastPinComment: "broadcastPinComment",
|
|
60
|
+
// Server to viewer only. A vote goes over HTTP, because it needs the unique
|
|
61
|
+
// index behind it; the result comes back here to everyone watching.
|
|
62
|
+
BroadcastPollStarted: "broadcastPollStarted",
|
|
63
|
+
BroadcastPollResults: "broadcastPollResults",
|
|
64
|
+
BroadcastPollEnded: "broadcastPollEnded",
|
|
38
65
|
} as const;
|
|
39
66
|
|
|
40
67
|
/**
|
|
@@ -124,15 +151,20 @@ export interface BroadcastPlayerProps {
|
|
|
124
151
|
*
|
|
125
152
|
* ## Why the quality picker is not on the new path
|
|
126
153
|
*
|
|
127
|
-
* The plane
|
|
128
|
-
*
|
|
129
|
-
*
|
|
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.
|
|
130
161
|
*/
|
|
131
162
|
export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
|
|
132
163
|
const t = useForgeT();
|
|
133
164
|
const theme = useForgeTheme();
|
|
134
165
|
const { apiUrl, profileId } = useForge();
|
|
135
166
|
const sessionApi = useBroadcastSessionApi();
|
|
167
|
+
const poll = useBroadcastPoll(broadcast.id);
|
|
136
168
|
|
|
137
169
|
const [comments, setComments] = useState<BroadcastComment[]>([]);
|
|
138
170
|
const [comment, setComment] = useState("");
|
|
@@ -169,6 +201,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
169
201
|
const [PlaneStage, setPlaneStage] = useState<ComponentType<BroadcastRealtimeStageProps> | null>(null);
|
|
170
202
|
const [credentialsReady, setCredentialsReady] = useState(false);
|
|
171
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);
|
|
172
215
|
const [roomState, setRoomState] = useState<BroadcastRoomState | undefined>(undefined);
|
|
173
216
|
/** The ticket the probe already minted, spent on the SDK's FIRST attempt. */
|
|
174
217
|
const primedCredentials = useRef<BroadcastViewerCredentials | null>(null);
|
|
@@ -214,6 +257,24 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
214
257
|
if (cancelled) return;
|
|
215
258
|
primedCredentials.current = credentials;
|
|
216
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
|
+
}
|
|
217
278
|
setCredentialsReady(true);
|
|
218
279
|
})
|
|
219
280
|
.catch((error: unknown) => {
|
|
@@ -257,11 +318,27 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
257
318
|
*/
|
|
258
319
|
const handleRoomState = useCallback((next: BroadcastRoomState) => setRoomState(next), []);
|
|
259
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
|
+
|
|
260
328
|
const stageInput = useMemo(
|
|
261
|
-
() => ({
|
|
262
|
-
|
|
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],
|
|
263
339
|
);
|
|
264
340
|
const stage = chooseBroadcastStage(stageInput);
|
|
341
|
+
const showMoq = stage === "moq" && !!moqStage && !!moqCredentials;
|
|
265
342
|
const showPlane = stage === "realtime" && !!PlaneStage;
|
|
266
343
|
// Nothing is mounted while the ticket is in flight. `react-player` builds an
|
|
267
344
|
// hls.js instance on mount and starts fetching immediately, so rendering the
|
|
@@ -364,6 +441,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
364
441
|
setComments((prev) => [...prev, data.message]);
|
|
365
442
|
return;
|
|
366
443
|
}
|
|
444
|
+
if (
|
|
445
|
+
message.event === SocketEvent.BroadcastPollStarted ||
|
|
446
|
+
message.event === SocketEvent.BroadcastPollResults
|
|
447
|
+
) {
|
|
448
|
+
poll.onPollEvent((message.data as { poll: BroadcastPoll }).poll);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (message.event === SocketEvent.BroadcastPollEnded) {
|
|
452
|
+
poll.onPollEvent(null);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
367
455
|
if (message.event === SocketEvent.BroadcastPinComment) {
|
|
368
456
|
const data = message.data as { broadcastId: string; comment: BroadcastComment | null };
|
|
369
457
|
setPinnedComment(data.comment);
|
|
@@ -371,7 +459,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
371
459
|
});
|
|
372
460
|
|
|
373
461
|
socketRef.current = socket;
|
|
374
|
-
}, [apiUrl, profileId, broadcast.id]);
|
|
462
|
+
}, [apiUrl, profileId, broadcast.id, poll.onPollEvent]);
|
|
375
463
|
|
|
376
464
|
const sendComment = () => {
|
|
377
465
|
if (!profileId || !socketRef.current || !comment.trim()) return;
|
|
@@ -461,6 +549,19 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
461
549
|
}}
|
|
462
550
|
>
|
|
463
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
|
+
)}
|
|
464
565
|
{showPlane && PlaneStage && <PlaneStage getCredentials={getCredentials} onRoomState={handleRoomState} />}
|
|
465
566
|
|
|
466
567
|
{stage === "pending" && (
|
|
@@ -470,7 +571,26 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
470
571
|
)}
|
|
471
572
|
|
|
472
573
|
{showHls && (
|
|
473
|
-
|
|
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 }}>
|
|
474
594
|
{renderPlayer ? (
|
|
475
595
|
renderPlayer({ src: broadcast.liveUrl })
|
|
476
596
|
) : (
|
|
@@ -479,9 +599,20 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
479
599
|
controls
|
|
480
600
|
autoPlay
|
|
481
601
|
playsInline
|
|
482
|
-
|
|
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
|
+
}}
|
|
483
613
|
/>
|
|
484
614
|
)}
|
|
615
|
+
</div>
|
|
485
616
|
</div>
|
|
486
617
|
)}
|
|
487
618
|
|
|
@@ -519,9 +650,44 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
519
650
|
onClick={togglePlay}
|
|
520
651
|
onMouseEnter={() => setShowControls(true)}
|
|
521
652
|
onMouseLeave={() => setShowControls(false)}
|
|
522
|
-
style={{
|
|
653
|
+
style={{
|
|
654
|
+
aspectRatio: "16/9",
|
|
655
|
+
position: "relative",
|
|
656
|
+
background: "#000",
|
|
657
|
+
cursor: "pointer",
|
|
658
|
+
lineHeight: 0,
|
|
659
|
+
}}
|
|
523
660
|
>
|
|
524
|
-
|
|
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
|
+
/>
|
|
525
691
|
<audio ref={audioRef} />
|
|
526
692
|
|
|
527
693
|
{(!isAudioLoaded || !isVideoLoaded) && (
|
|
@@ -654,6 +820,8 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
654
820
|
{t("forge.broadcast_player.chat")}
|
|
655
821
|
</p>
|
|
656
822
|
|
|
823
|
+
<BroadcastPollPanel poll={poll} />
|
|
824
|
+
|
|
657
825
|
{pinnedComment && (
|
|
658
826
|
<div
|
|
659
827
|
data-testid="broadcast-pinned-comment"
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { BarChart3 } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { useForgeT } from "../../../i18n";
|
|
4
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
5
|
+
import { pollPercentages } from "./pollResults";
|
|
6
|
+
import type { BroadcastPollApi } from "../../headless/broadcast/useBroadcastPoll";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The poll, beside the stream.
|
|
10
|
+
*
|
|
11
|
+
* This is the interactive half of the feature. The other half is a results
|
|
12
|
+
* block composited into the video itself by the studio, which is what an
|
|
13
|
+
* audience on YouTube or Twitch sees: they get the outcome, and only viewers on
|
|
14
|
+
* the creator's own site can actually answer.
|
|
15
|
+
*
|
|
16
|
+
* ## Results are shown after voting, not before
|
|
17
|
+
*
|
|
18
|
+
* A poll that shows the tallies while you are still choosing is not measuring
|
|
19
|
+
* what the room thinks, it is measuring what the room saw the room think. So
|
|
20
|
+
* the options are buttons until this browser has answered, and bars afterwards.
|
|
21
|
+
* A closed poll goes straight to bars for everybody.
|
|
22
|
+
*/
|
|
23
|
+
export function BroadcastPollPanel({ poll: api }: { poll: BroadcastPollApi }) {
|
|
24
|
+
const t = useForgeT();
|
|
25
|
+
const theme = useForgeTheme();
|
|
26
|
+
const { poll, votedOptionId, voting, error } = api;
|
|
27
|
+
|
|
28
|
+
if (!poll || poll.status === "draft") return null;
|
|
29
|
+
|
|
30
|
+
const showResults = !!votedOptionId || poll.status !== "live";
|
|
31
|
+
const results = pollPercentages(poll);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-testid="broadcast-poll"
|
|
36
|
+
style={{
|
|
37
|
+
margin: "8px 16px 0",
|
|
38
|
+
padding: 12,
|
|
39
|
+
backgroundColor: `${theme.colors.primary}15`,
|
|
40
|
+
border: `1px solid ${theme.colors.primary}50`,
|
|
41
|
+
borderRadius: theme.cornerRadius,
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
|
|
45
|
+
<BarChart3 size={14} style={{ color: theme.colors.primary, flexShrink: 0 }} />
|
|
46
|
+
<span style={{ fontSize: 12, fontWeight: 500, color: theme.colors.primary }}>
|
|
47
|
+
{poll.status === "live" ? t("forge.broadcast_poll.live") : t("forge.broadcast_poll.closed")}
|
|
48
|
+
</span>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<p style={{ fontSize: 14, fontWeight: 500, marginBottom: 10 }}>{poll.question}</p>
|
|
52
|
+
|
|
53
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
|
54
|
+
{results.map((option) => {
|
|
55
|
+
const chosen = option.id === votedOptionId;
|
|
56
|
+
|
|
57
|
+
if (!showResults) {
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
key={option.id}
|
|
61
|
+
type="button"
|
|
62
|
+
disabled={voting}
|
|
63
|
+
onClick={() => api.vote(option.id)}
|
|
64
|
+
style={{
|
|
65
|
+
textAlign: "left",
|
|
66
|
+
padding: "8px 12px",
|
|
67
|
+
fontSize: 14,
|
|
68
|
+
cursor: voting ? "default" : "pointer",
|
|
69
|
+
borderRadius: theme.cornerRadius,
|
|
70
|
+
border: `1px solid ${theme.colors.primary}50`,
|
|
71
|
+
background: "transparent",
|
|
72
|
+
color: "inherit",
|
|
73
|
+
opacity: voting ? 0.6 : 1,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{option.label}
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div key={option.id}>
|
|
83
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 13, marginBottom: 2 }}>
|
|
84
|
+
<span style={{ fontWeight: chosen ? 600 : 400 }}>
|
|
85
|
+
{option.label}
|
|
86
|
+
{chosen ? ` ${t("forge.broadcast_poll.your_vote")}` : ""}
|
|
87
|
+
</span>
|
|
88
|
+
<span style={{ opacity: 0.7 }}>{option.percent}%</span>
|
|
89
|
+
</div>
|
|
90
|
+
<div
|
|
91
|
+
style={{
|
|
92
|
+
height: 6,
|
|
93
|
+
borderRadius: 3,
|
|
94
|
+
overflow: "hidden",
|
|
95
|
+
backgroundColor: `${theme.colors.primary}25`,
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<div
|
|
99
|
+
style={{
|
|
100
|
+
height: "100%",
|
|
101
|
+
width: `${option.percent}%`,
|
|
102
|
+
backgroundColor: theme.colors.primary,
|
|
103
|
+
transition: "width 200ms",
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
})}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<p style={{ fontSize: 12, opacity: 0.7, marginTop: 8 }}>
|
|
113
|
+
{poll.totalVotes === 1
|
|
114
|
+
? t("forge.broadcast_poll.votes_one", { count: poll.totalVotes })
|
|
115
|
+
: t("forge.broadcast_poll.votes_other", { count: poll.totalVotes })}
|
|
116
|
+
</p>
|
|
117
|
+
|
|
118
|
+
{/* An error the viewer has to act on (press it again), so it is visible. */}
|
|
119
|
+
{error && <p style={{ fontSize: 12, marginTop: 4, color: theme.colors.primary }}>{error}</p>}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default BroadcastPollPanel;
|
|
@@ -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 = {
|