@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.
- package/package.json +1 -1
- package/src/data/queries/useBroadcasts.ts +122 -7
- package/src/i18n/de.json +10 -0
- package/src/i18n/en.json +10 -0
- package/src/types/models.ts +18 -0
- package/src/ui/headless/broadcast/useBroadcastPoll.ts +225 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +6 -2
- package/src/ui/index.ts +4 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +1 -0
- package/src/ui/styled/_tests/broadcastPollVote.spec.ts +127 -0
- package/src/ui/styled/_tests/broadcastStage.spec.ts +316 -0
- package/src/ui/styled/_tests/pollResults.spec.ts +66 -0
- package/src/ui/styled/_tests/uiEntryServerSafety.spec.ts +127 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +256 -15
- package/src/ui/styled/broadcast/BroadcastPollPanel.tsx +124 -0
- package/src/ui/styled/broadcast/BroadcastRealtimeStage.tsx +260 -0
- package/src/ui/styled/broadcast/broadcastStage.ts +189 -0
- package/src/ui/styled/broadcast/pollResults.ts +66 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react";
|
|
2
2
|
import { Maximize, Pause, Pin, Play, Send } from "lucide-react";
|
|
3
3
|
import { io, type Socket } from "socket.io-client";
|
|
4
4
|
import type { IBroadcastPass, ILiveBroadcast } from "../../../types/models";
|
|
@@ -10,18 +10,39 @@ import {
|
|
|
10
10
|
useBroadcastComments,
|
|
11
11
|
useBroadcastSessionApi,
|
|
12
12
|
type BroadcastComment,
|
|
13
|
+
type BroadcastViewerCredentials,
|
|
13
14
|
} from "../../../data/queries/useBroadcasts";
|
|
14
15
|
import { useForgeT } from "../../../i18n";
|
|
15
16
|
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
17
|
+
import { CallHelpHint } from "../../media/CallHelpHint";
|
|
16
18
|
import { timeAgo } from "../community/util";
|
|
17
19
|
import { buttonStyle } from "../Button";
|
|
18
20
|
import { Loading } from "../Loading";
|
|
21
|
+
import {
|
|
22
|
+
broadcastFellBack,
|
|
23
|
+
chooseBroadcastStage,
|
|
24
|
+
type BroadcastCredentialsError,
|
|
25
|
+
type BroadcastRoomState,
|
|
26
|
+
} from "./broadcastStage";
|
|
27
|
+
// Type-only, and it has to stay that way: the module it names imports
|
|
28
|
+
// `mediasoup-client`, and a value import here would put a browser-only package
|
|
29
|
+
// on the server path of every site that loads `@tribe-nest/forge/ui`. It is
|
|
30
|
+
// reached through the `import()` in the effect below, which no server runs.
|
|
31
|
+
import type { BroadcastRealtimeStageProps } from "./BroadcastRealtimeStage";
|
|
32
|
+
import { BroadcastPollPanel } from "./BroadcastPollPanel";
|
|
33
|
+
import { useBroadcastPoll } from "../../headless/broadcast/useBroadcastPoll";
|
|
34
|
+
import type { BroadcastPoll } from "../../../data/queries/useBroadcasts";
|
|
19
35
|
|
|
20
36
|
/** The socket messages a watching browser sends and receives. */
|
|
21
37
|
const SocketEvent = {
|
|
22
38
|
BroadcastChatMessage: "broadcastChatMessage",
|
|
23
39
|
BroadcastJoin: "broadcastJoin",
|
|
24
40
|
BroadcastPinComment: "broadcastPinComment",
|
|
41
|
+
// Server to viewer only. A vote goes over HTTP, because it needs the unique
|
|
42
|
+
// index behind it; the result comes back here to everyone watching.
|
|
43
|
+
BroadcastPollStarted: "broadcastPollStarted",
|
|
44
|
+
BroadcastPollResults: "broadcastPollResults",
|
|
45
|
+
BroadcastPollEnded: "broadcastPollEnded",
|
|
25
46
|
} as const;
|
|
26
47
|
|
|
27
48
|
/**
|
|
@@ -35,6 +56,26 @@ const SIMULCAST_QUALITIES = [
|
|
|
35
56
|
{ label: "forge.broadcast_player.quality_low", value: "q" },
|
|
36
57
|
] as const;
|
|
37
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The token endpoint's refusal, reduced to what the decision needs.
|
|
61
|
+
*
|
|
62
|
+
* Nothing here distinguishes a 409 from a 500 from a dead network, and that is
|
|
63
|
+
* the point: every one of them means this viewer is not getting into the live
|
|
64
|
+
* room, and every one of them ends with them watching the stream instead. The
|
|
65
|
+
* status and the message are carried so a site can log which it was.
|
|
66
|
+
*/
|
|
67
|
+
function credentialsRefusal(error: unknown): BroadcastCredentialsError {
|
|
68
|
+
const response = (error as { response?: { status?: number; data?: { reason?: unknown; message?: unknown } } } | null)
|
|
69
|
+
?.response;
|
|
70
|
+
const status = response?.status;
|
|
71
|
+
// The 409 answers with `{ reason }` (`full` or `unavailable`); everything
|
|
72
|
+
// else on this API answers with `{ message }`. Either is worth keeping for a
|
|
73
|
+
// log, and neither changes what happens next.
|
|
74
|
+
const detail = response?.data?.reason ?? response?.data?.message;
|
|
75
|
+
const message = typeof detail === "string" ? detail : undefined;
|
|
76
|
+
return { ...(typeof status === "number" ? { status } : {}), ...(message ? { message } : {}) };
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
const newId = (): string => {
|
|
39
80
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
40
81
|
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
@@ -65,15 +106,42 @@ export interface BroadcastPlayerProps {
|
|
|
65
106
|
/**
|
|
66
107
|
* The watch surface: the stream, who else is here, and the chat.
|
|
67
108
|
*
|
|
68
|
-
* Mounted ONLY behind a validated pass.
|
|
69
|
-
* broadcast itself
|
|
70
|
-
*
|
|
109
|
+
* Mounted ONLY behind a validated pass. Three playback paths, chosen by the
|
|
110
|
+
* broadcast itself and never by the viewer:
|
|
111
|
+
*
|
|
112
|
+
* 1. **The media plane** (`realtime.available`). A v2 broadcast whose live room
|
|
113
|
+
* is open, watched over WebRTC in well under a second. This is the path that
|
|
114
|
+
* can be REFUSED: the room has a hard cap, so being turned away is ordinary.
|
|
115
|
+
* 2. **Cloudflare Calls** (`realtimeConfig`). The v1 realtime path, unchanged
|
|
116
|
+
* and still carrying its quality picker, until v1 itself is retired.
|
|
117
|
+
* 3. **HLS.** Six to ten seconds behind and always available.
|
|
118
|
+
*
|
|
119
|
+
* ## Falling back is the feature, not the error path
|
|
120
|
+
*
|
|
121
|
+
* A viewer who cannot get onto the plane must end up watching the broadcast,
|
|
122
|
+
* without doing anything and without being asked anything. So every uncertain
|
|
123
|
+
* state resolves to HLS, and the four ways the plane can fail (the token
|
|
124
|
+
* endpoint answering 409, any other failure fetching one, the room closing, and
|
|
125
|
+
* the SDK giving up) all land in the same place. `chooseBroadcastStage` holds
|
|
126
|
+
* the rule and `broadcastStage.spec.ts` walks every combination of it.
|
|
127
|
+
*
|
|
128
|
+
* The one thing the viewer is told is a single line saying which stream they
|
|
129
|
+
* are on, because a stream that is suddenly eight seconds behind a chat they
|
|
130
|
+
* are reading is something a person needs an explanation for. Why it happened
|
|
131
|
+
* is behind the `?`, per the house design law.
|
|
132
|
+
*
|
|
133
|
+
* ## Why the quality picker is not on the new path
|
|
134
|
+
*
|
|
135
|
+
* The plane has no simulcast. Every viewer receives the program feed's single
|
|
136
|
+
* encoding, so High, Medium and Low would be three controls that change
|
|
137
|
+
* nothing. A control that lies about what it does is worse than no control.
|
|
71
138
|
*/
|
|
72
139
|
export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: BroadcastPlayerProps) {
|
|
73
140
|
const t = useForgeT();
|
|
74
141
|
const theme = useForgeTheme();
|
|
75
142
|
const { apiUrl, profileId } = useForge();
|
|
76
143
|
const sessionApi = useBroadcastSessionApi();
|
|
144
|
+
const poll = useBroadcastPoll(broadcast.id);
|
|
77
145
|
|
|
78
146
|
const [comments, setComments] = useState<BroadcastComment[]>([]);
|
|
79
147
|
const [comment, setComment] = useState("");
|
|
@@ -92,11 +160,125 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
92
160
|
const [realtimeSessionId, setRealtimeSessionId] = useState<string | null>(null);
|
|
93
161
|
const isSubscribingRef = useRef(false);
|
|
94
162
|
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
163
|
+
const realtimeAvailable = broadcast.realtime?.available === true;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The v1 Cloudflare path, and it yields to the plane rather than competing
|
|
167
|
+
* with it. A broadcast carries one or the other in practice; if one ever
|
|
168
|
+
* carried both, the newer plane is the one to attempt, and a plane that
|
|
169
|
+
* refuses falls back to HLS as the contract says rather than sideways into a
|
|
170
|
+
* v1 session that will not exist.
|
|
171
|
+
*/
|
|
172
|
+
const isLegacyRealtime = useMemo(
|
|
173
|
+
() => !realtimeAvailable && !!broadcast.realtimeConfig && Object.keys(broadcast.realtimeConfig).length > 0,
|
|
174
|
+
[realtimeAvailable, broadcast.realtimeConfig],
|
|
98
175
|
);
|
|
99
176
|
|
|
177
|
+
// ── The media plane ────────────────────────────────────────────────────────
|
|
178
|
+
const [PlaneStage, setPlaneStage] = useState<ComponentType<BroadcastRealtimeStageProps> | null>(null);
|
|
179
|
+
const [credentialsReady, setCredentialsReady] = useState(false);
|
|
180
|
+
const [credentialsError, setCredentialsError] = useState<BroadcastCredentialsError | null>(null);
|
|
181
|
+
const [roomState, setRoomState] = useState<BroadcastRoomState | undefined>(undefined);
|
|
182
|
+
/** The ticket the probe already minted, spent on the SDK's FIRST attempt. */
|
|
183
|
+
const primedCredentials = useRef<BroadcastViewerCredentials | null>(null);
|
|
184
|
+
|
|
185
|
+
// `sessionApi` is rebuilt every render, so it is read through a ref rather
|
|
186
|
+
// than depended on: as a dependency it would re-mint a ticket on every render.
|
|
187
|
+
const sessionApiRef = useRef(sessionApi);
|
|
188
|
+
sessionApiRef.current = sessionApi;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The pass is the credential this endpoint takes, and `sessionId` is the id
|
|
192
|
+
* the pass is known by everywhere else on this surface (it is what
|
|
193
|
+
* `validate-session` is given, and `viewer-token` validates the pass the same
|
|
194
|
+
* way before minting).
|
|
195
|
+
*/
|
|
196
|
+
const passId = broadcastPass.sessionId;
|
|
197
|
+
|
|
198
|
+
const mintCredentials = useCallback(
|
|
199
|
+
() => sessionApiRef.current.viewerToken({ broadcastId: broadcast.id, ...(passId ? { passId } : {}) }),
|
|
200
|
+
[broadcast.id, passId],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Load the SDK and mint a ticket, together, in an effect.
|
|
205
|
+
*
|
|
206
|
+
* The effect is what makes this safe on a server: Forge renders inside
|
|
207
|
+
* TanStack Start on Cloudflare Workers, `BroadcastRealtimeStage` imports
|
|
208
|
+
* `mediasoup-client`, and an effect is code no server ever runs. Nothing on
|
|
209
|
+
* the module path of this file touches the media SDK.
|
|
210
|
+
*
|
|
211
|
+
* They are fetched together because neither is any use without the other, and
|
|
212
|
+
* because a viewer must not be moved onto the plane until BOTH are in hand:
|
|
213
|
+
* mounting the provider first would show a black rectangle for as long as the
|
|
214
|
+
* chunk took to arrive. A chunk that never arrives lands in the same `catch`
|
|
215
|
+
* as a refused ticket and falls back the same way.
|
|
216
|
+
*/
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
if (!realtimeAvailable) return;
|
|
219
|
+
let cancelled = false;
|
|
220
|
+
|
|
221
|
+
Promise.all([import("./BroadcastRealtimeStage"), mintCredentials()])
|
|
222
|
+
.then(([module, credentials]) => {
|
|
223
|
+
if (cancelled) return;
|
|
224
|
+
primedCredentials.current = credentials;
|
|
225
|
+
setPlaneStage(() => module.BroadcastRealtimeStage);
|
|
226
|
+
setCredentialsReady(true);
|
|
227
|
+
})
|
|
228
|
+
.catch((error: unknown) => {
|
|
229
|
+
if (cancelled) return;
|
|
230
|
+
setCredentialsError(credentialsRefusal(error));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return () => {
|
|
234
|
+
cancelled = true;
|
|
235
|
+
};
|
|
236
|
+
}, [realtimeAvailable, mintCredentials]);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Handed to the provider, which calls it before EVERY attempt rather than
|
|
240
|
+
* once. A viewer ticket expires in ten minutes and a broadcast runs longer,
|
|
241
|
+
* so a token fetched once means the first reconnect presents a dead one.
|
|
242
|
+
*
|
|
243
|
+
* The probe's ticket is spent here on the first call, so one viewer does not
|
|
244
|
+
* mint two identities to watch one broadcast.
|
|
245
|
+
*/
|
|
246
|
+
const getCredentials = useCallback(async () => {
|
|
247
|
+
const primed = primedCredentials.current;
|
|
248
|
+
primedCredentials.current = null;
|
|
249
|
+
if (primed) return primed;
|
|
250
|
+
try {
|
|
251
|
+
return await mintCredentials();
|
|
252
|
+
} catch (error) {
|
|
253
|
+
// A refusal on a reconnect says the same thing as a refusal on the first
|
|
254
|
+
// attempt: this viewer is not getting back into the room. Recording it is
|
|
255
|
+
// what moves them onto HLS instead of leaving the SDK knocking.
|
|
256
|
+
setCredentialsError(credentialsRefusal(error));
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
}, [mintCredentials]);
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Never cleared back to `undefined` when the stage unmounts, and that is
|
|
263
|
+
* load-bearing. A room that has closed is the reason we left it, so forgetting
|
|
264
|
+
* it would choose the plane again, mount the provider again, and watch it
|
|
265
|
+
* close again, for ever.
|
|
266
|
+
*/
|
|
267
|
+
const handleRoomState = useCallback((next: BroadcastRoomState) => setRoomState(next), []);
|
|
268
|
+
|
|
269
|
+
const stageInput = useMemo(
|
|
270
|
+
() => ({ realtimeAvailable, credentialsReady, credentialsError, roomState }),
|
|
271
|
+
[realtimeAvailable, credentialsReady, credentialsError, roomState],
|
|
272
|
+
);
|
|
273
|
+
const stage = chooseBroadcastStage(stageInput);
|
|
274
|
+
const showPlane = stage === "realtime" && !!PlaneStage;
|
|
275
|
+
// Nothing is mounted while the ticket is in flight. `react-player` builds an
|
|
276
|
+
// hls.js instance on mount and starts fetching immediately, so rendering the
|
|
277
|
+
// HLS stage "meanwhile" made every realtime viewer download the broadcast a
|
|
278
|
+
// second time, over the CDN, for a stage that was about to be replaced.
|
|
279
|
+
const showHls = stage === "hls" && !isLegacyRealtime;
|
|
280
|
+
const fellBack = broadcastFellBack(stageInput);
|
|
281
|
+
|
|
100
282
|
const { data: audienceData } = useBroadcastAudience(broadcast.id, !broadcast.endedAt);
|
|
101
283
|
const { data: initialComments } = useBroadcastComments(broadcast.id);
|
|
102
284
|
|
|
@@ -113,10 +295,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
113
295
|
});
|
|
114
296
|
}, [comments]);
|
|
115
297
|
|
|
116
|
-
// ── WebRTC subscription
|
|
298
|
+
// ── WebRTC subscription, v1 (Cloudflare Calls) ─────────────────────────────
|
|
117
299
|
useEffect(() => {
|
|
118
300
|
const config = broadcast.realtimeConfig;
|
|
119
|
-
if (!
|
|
301
|
+
if (!isLegacyRealtime || !config) return;
|
|
120
302
|
if (isSubscribingRef.current) return;
|
|
121
303
|
isSubscribingRef.current = true;
|
|
122
304
|
|
|
@@ -150,6 +332,10 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
150
332
|
type: local?.type,
|
|
151
333
|
trackIds: config.tracks.map((track) => track.trackName),
|
|
152
334
|
sessionId: config.sessionId,
|
|
335
|
+
// The pass, because subscribing IS playback: this endpoint used to
|
|
336
|
+
// take a session id and a track name from anybody and hand back the
|
|
337
|
+
// stream, which made the ticket box in front of it decoration.
|
|
338
|
+
passId,
|
|
153
339
|
});
|
|
154
340
|
|
|
155
341
|
setRealtimeSessionId(answer.sessionId);
|
|
@@ -166,7 +352,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
166
352
|
// `sessionApi` is rebuilt each render and is deliberately not a dependency:
|
|
167
353
|
// including it would re-negotiate the peer connection on every render.
|
|
168
354
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
169
|
-
}, [
|
|
355
|
+
}, [isLegacyRealtime, broadcast.realtimeConfig]);
|
|
170
356
|
|
|
171
357
|
// ── Chat socket ────────────────────────────────────────────────────────────
|
|
172
358
|
useEffect(() => {
|
|
@@ -187,6 +373,17 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
187
373
|
setComments((prev) => [...prev, data.message]);
|
|
188
374
|
return;
|
|
189
375
|
}
|
|
376
|
+
if (
|
|
377
|
+
message.event === SocketEvent.BroadcastPollStarted ||
|
|
378
|
+
message.event === SocketEvent.BroadcastPollResults
|
|
379
|
+
) {
|
|
380
|
+
poll.onPollEvent((message.data as { poll: BroadcastPoll }).poll);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (message.event === SocketEvent.BroadcastPollEnded) {
|
|
384
|
+
poll.onPollEvent(null);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
190
387
|
if (message.event === SocketEvent.BroadcastPinComment) {
|
|
191
388
|
const data = message.data as { broadcastId: string; comment: BroadcastComment | null };
|
|
192
389
|
setPinnedComment(data.comment);
|
|
@@ -194,7 +391,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
194
391
|
});
|
|
195
392
|
|
|
196
393
|
socketRef.current = socket;
|
|
197
|
-
}, [apiUrl, profileId, broadcast.id]);
|
|
394
|
+
}, [apiUrl, profileId, broadcast.id, poll.onPollEvent]);
|
|
198
395
|
|
|
199
396
|
const sendComment = () => {
|
|
200
397
|
if (!profileId || !socketRef.current || !comment.trim()) return;
|
|
@@ -213,7 +410,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
213
410
|
};
|
|
214
411
|
|
|
215
412
|
const togglePlay = () => {
|
|
216
|
-
if (!
|
|
413
|
+
if (!isLegacyRealtime || !isAudioLoaded || !isVideoLoaded) return;
|
|
217
414
|
if (isPlaying) {
|
|
218
415
|
audioRef.current?.pause();
|
|
219
416
|
videoRef.current?.pause();
|
|
@@ -239,6 +436,7 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
239
436
|
const videoTrack = config.tracks.find((track) => track.mid === "0");
|
|
240
437
|
try {
|
|
241
438
|
await sessionApi.switchQuality({
|
|
439
|
+
passId,
|
|
242
440
|
track: {
|
|
243
441
|
...videoTrack,
|
|
244
442
|
sessionId: realtimeSessionId,
|
|
@@ -283,7 +481,15 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
283
481
|
}}
|
|
284
482
|
>
|
|
285
483
|
<div style={{ flex: "1 1 520px", minWidth: 0, ...panel }}>
|
|
286
|
-
{
|
|
484
|
+
{showPlane && PlaneStage && <PlaneStage getCredentials={getCredentials} onRoomState={handleRoomState} />}
|
|
485
|
+
|
|
486
|
+
{stage === "pending" && (
|
|
487
|
+
<div style={{ aspectRatio: "16/9", display: "grid", placeItems: "center" }}>
|
|
488
|
+
<Loading />
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
|
|
492
|
+
{showHls && (
|
|
287
493
|
<div data-testid="broadcast-hls-stage" style={{ aspectRatio: "16/9" }}>
|
|
288
494
|
{renderPlayer ? (
|
|
289
495
|
renderPlayer({ src: broadcast.liveUrl })
|
|
@@ -299,7 +505,34 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
299
505
|
</div>
|
|
300
506
|
)}
|
|
301
507
|
|
|
302
|
-
{
|
|
508
|
+
{/*
|
|
509
|
+
Which stream they ended up on. This is STATE, not an explanation, so
|
|
510
|
+
it stays visible: a viewer whose picture is suddenly eight seconds
|
|
511
|
+
behind the chat beside it needs to know that before they go looking
|
|
512
|
+
for a fault. The reason it happened is help, so it is behind the `?`.
|
|
513
|
+
*/}
|
|
514
|
+
{fellBack && (
|
|
515
|
+
<div
|
|
516
|
+
role="status"
|
|
517
|
+
data-testid="broadcast-fallback-notice"
|
|
518
|
+
style={{
|
|
519
|
+
display: "flex",
|
|
520
|
+
alignItems: "center",
|
|
521
|
+
gap: 6,
|
|
522
|
+
padding: "8px 16px 0",
|
|
523
|
+
fontSize: 13,
|
|
524
|
+
color: theme.colors.text,
|
|
525
|
+
opacity: 0.75,
|
|
526
|
+
}}
|
|
527
|
+
>
|
|
528
|
+
<span>{t("forge.broadcast_player.fallback_notice")}</span>
|
|
529
|
+
<CallHelpHint label={t("forge.broadcast_player.fallback_help_label")}>
|
|
530
|
+
{t("forge.broadcast_player.fallback_help")}
|
|
531
|
+
</CallHelpHint>
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
|
|
535
|
+
{isLegacyRealtime && (
|
|
303
536
|
<div
|
|
304
537
|
ref={videoContainerRef}
|
|
305
538
|
data-testid="broadcast-realtime-stage"
|
|
@@ -403,7 +636,13 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
403
636
|
</div>
|
|
404
637
|
</div>
|
|
405
638
|
|
|
406
|
-
{
|
|
639
|
+
{/*
|
|
640
|
+
v1 only. The media plane has no simulcast, so on that path these
|
|
641
|
+
four options would all resolve to the one encoding the program feed
|
|
642
|
+
publishes, and a picker that cannot change anything is a lie about
|
|
643
|
+
what the viewer is receiving.
|
|
644
|
+
*/}
|
|
645
|
+
{isLegacyRealtime && (
|
|
407
646
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
408
647
|
<label htmlFor="broadcast-quality">{t("forge.broadcast_player.quality")}</label>
|
|
409
648
|
<select
|
|
@@ -435,6 +674,8 @@ export function BroadcastPlayer({ broadcast, broadcastPass, renderPlayer }: Broa
|
|
|
435
674
|
{t("forge.broadcast_player.chat")}
|
|
436
675
|
</p>
|
|
437
676
|
|
|
677
|
+
<BroadcastPollPanel poll={poll} />
|
|
678
|
+
|
|
438
679
|
{pinnedComment && (
|
|
439
680
|
<div
|
|
440
681
|
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;
|