@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.
@@ -28,7 +28,16 @@ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from
28
28
  * picture.
29
29
  */
30
30
 
31
- /** `realtime` is the media plane. `hls` is the stream everybody can always watch. */
31
+ /**
32
+ * `moq` is a relay, `realtime` is the media plane, and `hls` is the stream
33
+ * everybody can always watch.
34
+ *
35
+ * `moq` outranks `realtime` because the two show the same picture at the same
36
+ * latency and cost very different amounts: a relay viewer is one more
37
+ * subscriber on a cached object, and a plane viewer is a WebRTC consumer with
38
+ * its own SRTP context and its own bandwidth estimate, capped at
39
+ * `STREAM_V2_REALTIME_VIEWER_CAP` because the node runs out.
40
+ */
32
41
  /**
33
42
  * Which stage the player shows.
34
43
  *
@@ -37,7 +46,7 @@ import type { ConnectionState, DisconnectCause, ProducerEntry, RoomState } from
37
46
  * was showing HLS meanwhile, and an HLS engine starts fetching the moment it
38
47
  * mounts, so a realtime viewer pulled segments they would never watch.
39
48
  */
40
- export type BroadcastStage = "realtime" | "hls" | "pending";
49
+ export type BroadcastStage = "moq" | "realtime" | "hls" | "pending";
41
50
 
42
51
  /**
43
52
  * The viewer-token endpoint said no.
@@ -78,6 +87,25 @@ export type BroadcastStageInput = {
78
87
  /** The refusal, when the fetch failed. `null` while it has not. */
79
88
  credentialsError: BroadcastCredentialsError | null;
80
89
  roomState?: BroadcastRoomState;
90
+ /** The token endpoint returned a relay ticket for this viewer. */
91
+ moqAvailable?: boolean;
92
+ /**
93
+ * The relay lane was tried and is over.
94
+ *
95
+ * Separate from `moqAvailable` for the same reason `realtimeIsOver` is
96
+ * separate from `credentialsReady`: a ticket that exists is not a lane that
97
+ * is working, and the fallback has to be able to happen after a connection
98
+ * that started fine.
99
+ */
100
+ moqFailed?: boolean;
101
+ /**
102
+ * Is the plane lane offered at all?
103
+ *
104
+ * `false` on an instance that runs relays and no media plane, which is what
105
+ * `credentials: null` from `viewer-token` means. Without it the chooser would
106
+ * fall to `realtime` after a relay failure and mount a room that is not there.
107
+ */
108
+ planeAvailable?: boolean;
81
109
  };
82
110
 
83
111
  /**
@@ -115,7 +143,12 @@ export function chooseBroadcastStage(input: BroadcastStageInput): BroadcastStage
115
143
  // about to be replaced, so a realtime viewer downloaded the broadcast twice.
116
144
  // Nothing is drawn until the answer is known.
117
145
  if (!input.credentialsReady) return "pending";
146
+ // The relay first, and only while it is actually working.
147
+ if (input.moqAvailable && !input.moqFailed) return "moq";
118
148
  if (realtimeIsOver(input.roomState)) return "hls";
149
+ // The relay failed and there is no plane behind it. `planeAvailable` defaults
150
+ // to true so every existing caller keeps its behaviour unchanged.
151
+ if (input.planeAvailable === false) return "hls";
119
152
  return "realtime";
120
153
  }
121
154
 
@@ -135,6 +168,9 @@ export function broadcastFellBack(input: BroadcastStageInput): boolean {
135
168
  if (!input.realtimeAvailable) return false;
136
169
  if (input.credentialsError) return true;
137
170
  if (!input.credentialsReady) return false;
171
+ // A working relay is not a fallback, it is the best lane there is.
172
+ if (input.moqAvailable && !input.moqFailed) return false;
173
+ if (input.moqAvailable && input.moqFailed && input.planeAvailable === false) return true;
138
174
  return realtimeIsOver(input.roomState);
139
175
  }
140
176
 
@@ -0,0 +1,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
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@moq/watch`, vendored as a self-contained ESM bundle.
3
+ *
4
+ * Side-effect only: importing it registers the `<moq-watch>` custom element.
5
+ * There is nothing to import by name, which is why this declares an empty
6
+ * module rather than a surface.
7
+ *
8
+ * Built by `scripts/build-moq-player.mjs`; see its header for why this is an
9
+ * asset rather than a dependency.
10
+ */
11
+ declare const _default: void;
12
+ export default _default;