@torrent-tv/proxy 2.83.5 → 2.83.7
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/CHANGELOG.md +1874 -1860
- package/bin/cli.js +14 -5
- package/package.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -0
- package/research/handover-2026-09-13.md +209 -0
- package/routes/api/subtitles/get.js +26 -1
- package/server.js +2 -1
- package/services/data-channel-handler.js +120 -83
- package/services/orchestrators/EncodeOrchestrator.js +42 -12
- package/services/torrent-pool.js +3 -3
- package/services/torrent-worker/client.js +5 -2
- package/services/torrent-worker/worker.js +21 -2
- package/services/viewer/Viewer.js +359 -345
- package/services/viewer/Viewers.js +44 -0
- package/test/logger-repeats.test.js +15 -1
- package/test/send-chunk-bytes.test.js +86 -0
- package/test/viewer-subtitle-subscription.test.js +96 -0
- package/utils/logger.js +44 -14
|
@@ -1,345 +1,359 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file One person watching, and everything that is true of them alone.
|
|
3
|
-
*
|
|
4
|
-
* A viewer is not a property of the material. What they are listening to, which
|
|
5
|
-
* quality step they have on screen, what is being prepared for them, where they
|
|
6
|
-
* are and what their link can carry — none of it changes a byte of what any
|
|
7
|
-
* encoder produces, and none of it belongs to a session, which is a description
|
|
8
|
-
* of an OUTPUT.
|
|
9
|
-
*
|
|
10
|
-
* It was six parallel maps hung on the session, each keyed by consumer id:
|
|
11
|
-
* `audioChoiceByConsumer`, `activeVariantByConsumer`,
|
|
12
|
-
* `warmingVariantByConsumer`, `warmingAudioByConsumer`, `consumerHeads`,
|
|
13
|
-
* `netReports`. Six places to remember to update and six to remember to forget,
|
|
14
|
-
* and the forgetting was already wrong — releasing a consumer emptied none of
|
|
15
|
-
* them, so a viewer who had left went on counting as wanting their soundtrack
|
|
16
|
-
* until their head expired, and their entries stayed for the life of the
|
|
17
|
-
* session.
|
|
18
|
-
*
|
|
19
|
-
* One object, one map, one thing to remove.
|
|
20
|
-
*
|
|
21
|
-
* **TWO INDEPENDENT FACTS, NOT ONE.** A viewer is somewhere, and a viewer is
|
|
22
|
-
* either still here or gone. Until 2026-09-05 both were answered by one field —
|
|
23
|
-
* the position, which is written only when a segment is requested — so a viewer
|
|
24
|
-
* who had just arrived counted as absent, and an output all of whose viewers
|
|
25
|
-
* count as absent has every encoder on it stopped. That is exactly what
|
|
26
|
-
* happened on 2026-09-05: a soundtrack's encoder was stopped 1.25 s after it
|
|
27
|
-
* started, having produced nothing, its `init.mp4` was therefore never made,
|
|
28
|
-
* and the picture could not be played without it. The browser could not rescue
|
|
29
|
-
* itself either, because the only thing that would have marked the viewer
|
|
30
|
-
* present was a request for a segment — which needs the `init.mp4` that the
|
|
31
|
-
* stopped encoder was going to make.
|
|
32
|
-
*
|
|
33
|
-
* So: **position is known from the moment a viewer arrives** — it is in the
|
|
34
|
-
* request that created the output, as a time on the source, and it is either
|
|
35
|
-
* zero or what the address bar carried. And **presence is a fact of the
|
|
36
|
-
* connection**, not of the last file asked for.
|
|
37
|
-
*
|
|
38
|
-
* **Nothing here knows about ffmpeg, the disk or the torrent.** A viewer states
|
|
39
|
-
* what they want and where they are; what to make of that is the orchestrator's
|
|
40
|
-
* question, and it reads a union of viewers rather than any one of them.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
export class Viewer {
|
|
44
|
-
/**
|
|
45
|
-
* @param {string} id - The consumer id the browser sends with every request
|
|
46
|
-
* that means "this viewer".
|
|
47
|
-
* @param {number} [now] - When they arrived. Presence starts here, so a
|
|
48
|
-
* viewer counts as watching from the instant they are known.
|
|
49
|
-
*/
|
|
50
|
-
constructor(id, now = Date.now()) {
|
|
51
|
-
this.id = String(id ?? "");
|
|
52
|
-
/**
|
|
53
|
-
* Which soundtrack this viewer is listening to and whether their browser
|
|
54
|
-
* needs it re-encoded. Theirs alone: two viewers of one picture may have
|
|
55
|
-
* chosen different languages, and one browser may decode a track another
|
|
56
|
-
* cannot.
|
|
57
|
-
* @type {{ trackIndex: number, transcode: boolean }}
|
|
58
|
-
*/
|
|
59
|
-
this.audio = { trackIndex: 0, transcode: false };
|
|
60
|
-
/** The quality step on their screen. Null means the base session. @type {string | null} */
|
|
61
|
-
this.activeVariantId = null;
|
|
62
|
-
/** A step being prepared for a switch they have not made yet. @type {string | null} */
|
|
63
|
-
this.warmingVariantId = null;
|
|
64
|
-
/** A soundtrack being prepared for the same reason. @type {string | null} */
|
|
65
|
-
this.warmingAudioId = null;
|
|
66
|
-
/**
|
|
67
|
-
* Where they are.
|
|
68
|
-
*
|
|
69
|
-
* Set when they arrive, from the position their own request named, and
|
|
70
|
-
* moved by the segments they ask for and by the seeks they report. Never
|
|
71
|
-
* null for a viewer this process has met: "we do not know where they are"
|
|
72
|
-
* is not a state a viewer can be in, because a viewer arrives by asking for
|
|
73
|
-
* a position.
|
|
74
|
-
*
|
|
75
|
-
* `seeked` holds a position they STATED, for as long as they stay there,
|
|
76
|
-
* which is the distinction a cold open's soundtrack placement turns on: a
|
|
77
|
-
* request is evidence about where their player is reading, a seek is the
|
|
78
|
-
* viewer saying where they are.
|
|
79
|
-
*
|
|
80
|
-
* @type {{ segment: number, seconds: number, at: number, seeked?: number | null } | null}
|
|
81
|
-
*/
|
|
82
|
-
this.position = null;
|
|
83
|
-
/**
|
|
84
|
-
* When this viewer was last known to be there.
|
|
85
|
-
*
|
|
86
|
-
* Every piece of evidence refreshes it: a request of any kind, a link
|
|
87
|
-
* report, an echo of a delivery probe. It is NOT the position's timestamp —
|
|
88
|
-
* a viewer with a full buffer legitimately asks for nothing for a minute
|
|
89
|
-
* and is no less present for it.
|
|
90
|
-
*
|
|
91
|
-
* @type {number}
|
|
92
|
-
*/
|
|
93
|
-
this.lastSeenAt = now;
|
|
94
|
-
/**
|
|
95
|
-
* Set when something has SAID this viewer is gone — the browser released
|
|
96
|
-
* the session, or their connection closed. Silence never sets it: a
|
|
97
|
-
* paused viewer, a viewer whose tab is hidden and whose timers the browser
|
|
98
|
-
* has throttled, and a viewer holding two minutes of buffer are all silent
|
|
99
|
-
* and all still watching.
|
|
100
|
-
*
|
|
101
|
-
* @type {boolean}
|
|
102
|
-
*/
|
|
103
|
-
this.gone = false;
|
|
104
|
-
/** What their link was last measured to carry. @type {object | null} */
|
|
105
|
-
this.netReport = null;
|
|
106
|
-
/**
|
|
107
|
-
* Every output this viewer is watching, by session id: the picture, the
|
|
108
|
-
* quality step on their screen, the soundtrack they chose.
|
|
109
|
-
*
|
|
110
|
-
* WHY THERE ARE TWO SETS AND NOT ONE. There is one relation — this person
|
|
111
|
-
* watches this output — and it is asked from both ends. An output asks "has
|
|
112
|
-
* anybody left?", to decide whether to go on producing. A viewer who leaves
|
|
113
|
-
* asks "what was I watching?", so that each of those outputs can be told.
|
|
114
|
-
* Neither question can be answered from the other side without walking every
|
|
115
|
-
* session in the process, so the relation is indexed both ways. It is
|
|
116
|
-
* written in exactly one place — `Viewers.of` and `Viewers.leaves` write
|
|
117
|
-
* both directions together — which is what keeps two indexes of one relation
|
|
118
|
-
* from becoming two different answers.
|
|
119
|
-
*
|
|
120
|
-
* This is what replaced a film object. There is no "film" anywhere in this
|
|
121
|
-
* proxy — its parts are born at different times, die at different times and
|
|
122
|
-
* are addressed separately — and the three link fields that stood in for one
|
|
123
|
-
* could not say how many people were listening to a soundtrack.
|
|
124
|
-
*
|
|
125
|
-
* @type {Set<string>}
|
|
126
|
-
*/
|
|
127
|
-
this.outputs = new Set();
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
this.
|
|
149
|
-
this
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
this.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
*
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file One person watching, and everything that is true of them alone.
|
|
3
|
+
*
|
|
4
|
+
* A viewer is not a property of the material. What they are listening to, which
|
|
5
|
+
* quality step they have on screen, what is being prepared for them, where they
|
|
6
|
+
* are and what their link can carry — none of it changes a byte of what any
|
|
7
|
+
* encoder produces, and none of it belongs to a session, which is a description
|
|
8
|
+
* of an OUTPUT.
|
|
9
|
+
*
|
|
10
|
+
* It was six parallel maps hung on the session, each keyed by consumer id:
|
|
11
|
+
* `audioChoiceByConsumer`, `activeVariantByConsumer`,
|
|
12
|
+
* `warmingVariantByConsumer`, `warmingAudioByConsumer`, `consumerHeads`,
|
|
13
|
+
* `netReports`. Six places to remember to update and six to remember to forget,
|
|
14
|
+
* and the forgetting was already wrong — releasing a consumer emptied none of
|
|
15
|
+
* them, so a viewer who had left went on counting as wanting their soundtrack
|
|
16
|
+
* until their head expired, and their entries stayed for the life of the
|
|
17
|
+
* session.
|
|
18
|
+
*
|
|
19
|
+
* One object, one map, one thing to remove.
|
|
20
|
+
*
|
|
21
|
+
* **TWO INDEPENDENT FACTS, NOT ONE.** A viewer is somewhere, and a viewer is
|
|
22
|
+
* either still here or gone. Until 2026-09-05 both were answered by one field —
|
|
23
|
+
* the position, which is written only when a segment is requested — so a viewer
|
|
24
|
+
* who had just arrived counted as absent, and an output all of whose viewers
|
|
25
|
+
* count as absent has every encoder on it stopped. That is exactly what
|
|
26
|
+
* happened on 2026-09-05: a soundtrack's encoder was stopped 1.25 s after it
|
|
27
|
+
* started, having produced nothing, its `init.mp4` was therefore never made,
|
|
28
|
+
* and the picture could not be played without it. The browser could not rescue
|
|
29
|
+
* itself either, because the only thing that would have marked the viewer
|
|
30
|
+
* present was a request for a segment — which needs the `init.mp4` that the
|
|
31
|
+
* stopped encoder was going to make.
|
|
32
|
+
*
|
|
33
|
+
* So: **position is known from the moment a viewer arrives** — it is in the
|
|
34
|
+
* request that created the output, as a time on the source, and it is either
|
|
35
|
+
* zero or what the address bar carried. And **presence is a fact of the
|
|
36
|
+
* connection**, not of the last file asked for.
|
|
37
|
+
*
|
|
38
|
+
* **Nothing here knows about ffmpeg, the disk or the torrent.** A viewer states
|
|
39
|
+
* what they want and where they are; what to make of that is the orchestrator's
|
|
40
|
+
* question, and it reads a union of viewers rather than any one of them.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export class Viewer {
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} id - The consumer id the browser sends with every request
|
|
46
|
+
* that means "this viewer".
|
|
47
|
+
* @param {number} [now] - When they arrived. Presence starts here, so a
|
|
48
|
+
* viewer counts as watching from the instant they are known.
|
|
49
|
+
*/
|
|
50
|
+
constructor(id, now = Date.now()) {
|
|
51
|
+
this.id = String(id ?? "");
|
|
52
|
+
/**
|
|
53
|
+
* Which soundtrack this viewer is listening to and whether their browser
|
|
54
|
+
* needs it re-encoded. Theirs alone: two viewers of one picture may have
|
|
55
|
+
* chosen different languages, and one browser may decode a track another
|
|
56
|
+
* cannot.
|
|
57
|
+
* @type {{ trackIndex: number, transcode: boolean }}
|
|
58
|
+
*/
|
|
59
|
+
this.audio = { trackIndex: 0, transcode: false };
|
|
60
|
+
/** The quality step on their screen. Null means the base session. @type {string | null} */
|
|
61
|
+
this.activeVariantId = null;
|
|
62
|
+
/** A step being prepared for a switch they have not made yet. @type {string | null} */
|
|
63
|
+
this.warmingVariantId = null;
|
|
64
|
+
/** A soundtrack being prepared for the same reason. @type {string | null} */
|
|
65
|
+
this.warmingAudioId = null;
|
|
66
|
+
/**
|
|
67
|
+
* Where they are.
|
|
68
|
+
*
|
|
69
|
+
* Set when they arrive, from the position their own request named, and
|
|
70
|
+
* moved by the segments they ask for and by the seeks they report. Never
|
|
71
|
+
* null for a viewer this process has met: "we do not know where they are"
|
|
72
|
+
* is not a state a viewer can be in, because a viewer arrives by asking for
|
|
73
|
+
* a position.
|
|
74
|
+
*
|
|
75
|
+
* `seeked` holds a position they STATED, for as long as they stay there,
|
|
76
|
+
* which is the distinction a cold open's soundtrack placement turns on: a
|
|
77
|
+
* request is evidence about where their player is reading, a seek is the
|
|
78
|
+
* viewer saying where they are.
|
|
79
|
+
*
|
|
80
|
+
* @type {{ segment: number, seconds: number, at: number, seeked?: number | null } | null}
|
|
81
|
+
*/
|
|
82
|
+
this.position = null;
|
|
83
|
+
/**
|
|
84
|
+
* When this viewer was last known to be there.
|
|
85
|
+
*
|
|
86
|
+
* Every piece of evidence refreshes it: a request of any kind, a link
|
|
87
|
+
* report, an echo of a delivery probe. It is NOT the position's timestamp —
|
|
88
|
+
* a viewer with a full buffer legitimately asks for nothing for a minute
|
|
89
|
+
* and is no less present for it.
|
|
90
|
+
*
|
|
91
|
+
* @type {number}
|
|
92
|
+
*/
|
|
93
|
+
this.lastSeenAt = now;
|
|
94
|
+
/**
|
|
95
|
+
* Set when something has SAID this viewer is gone — the browser released
|
|
96
|
+
* the session, or their connection closed. Silence never sets it: a
|
|
97
|
+
* paused viewer, a viewer whose tab is hidden and whose timers the browser
|
|
98
|
+
* has throttled, and a viewer holding two minutes of buffer are all silent
|
|
99
|
+
* and all still watching.
|
|
100
|
+
*
|
|
101
|
+
* @type {boolean}
|
|
102
|
+
*/
|
|
103
|
+
this.gone = false;
|
|
104
|
+
/** What their link was last measured to carry. @type {object | null} */
|
|
105
|
+
this.netReport = null;
|
|
106
|
+
/**
|
|
107
|
+
* Every output this viewer is watching, by session id: the picture, the
|
|
108
|
+
* quality step on their screen, the soundtrack they chose.
|
|
109
|
+
*
|
|
110
|
+
* WHY THERE ARE TWO SETS AND NOT ONE. There is one relation — this person
|
|
111
|
+
* watches this output — and it is asked from both ends. An output asks "has
|
|
112
|
+
* anybody left?", to decide whether to go on producing. A viewer who leaves
|
|
113
|
+
* asks "what was I watching?", so that each of those outputs can be told.
|
|
114
|
+
* Neither question can be answered from the other side without walking every
|
|
115
|
+
* session in the process, so the relation is indexed both ways. It is
|
|
116
|
+
* written in exactly one place — `Viewers.of` and `Viewers.leaves` write
|
|
117
|
+
* both directions together — which is what keeps two indexes of one relation
|
|
118
|
+
* from becoming two different answers.
|
|
119
|
+
*
|
|
120
|
+
* This is what replaced a film object. There is no "film" anywhere in this
|
|
121
|
+
* proxy — its parts are born at different times, die at different times and
|
|
122
|
+
* are addressed separately — and the three link fields that stood in for one
|
|
123
|
+
* could not say how many people were listening to a soundtrack.
|
|
124
|
+
*
|
|
125
|
+
* @type {Set<string>}
|
|
126
|
+
*/
|
|
127
|
+
this.outputs = new Set();
|
|
128
|
+
/**
|
|
129
|
+
* The files this viewer has subtitles switched on for, as
|
|
130
|
+
* `sourceKey:fileIndex`.
|
|
131
|
+
*
|
|
132
|
+
* A subscription is a fact about a PERSON, not about a channel. Held on the
|
|
133
|
+
* channel — as it was until now — it dies with the channel, so a seamless
|
|
134
|
+
* reconnect silently lost subtitles for the rest of the session, and a
|
|
135
|
+
* transport that rotates its association on purpose would lose them every
|
|
136
|
+
* time it rotated. Held here it survives both by construction: whatever
|
|
137
|
+
* channel this viewer is reachable on next, they are still subscribed.
|
|
138
|
+
*
|
|
139
|
+
* @type {Set<string>}
|
|
140
|
+
*/
|
|
141
|
+
this.wantsCuesFor = new Set();
|
|
142
|
+
// Whether the picture is moving. A viewer who has stopped it consumes
|
|
143
|
+
// nothing, so nothing in front of them ever becomes due — they have no
|
|
144
|
+
// deadline at all, and the work goes to whoever is watching. The page knows
|
|
145
|
+
// this exactly and says it outright; inferring it from a position that has
|
|
146
|
+
// not moved takes two reports and lies whenever a browser holding a full
|
|
147
|
+
// cushion goes quiet between segments, which it does.
|
|
148
|
+
this.playing = true;
|
|
149
|
+
// Whether the page carrying this viewer is ON SCREEN, and whether the
|
|
150
|
+
// picture has been pulled out of it.
|
|
151
|
+
//
|
|
152
|
+
// Two facts, not one, and the second is why the first is not enough: a
|
|
153
|
+
// hidden tab has its timers throttled by the browser — 800 ms of event-loop
|
|
154
|
+
// lag measured in the field — so it asks for nothing and looks exactly like
|
|
155
|
+
// a viewer holding a full cushion. Delivery stood still for the last six
|
|
156
|
+
// minutes of the session of 2026-09-08 and nothing anywhere said the tab had
|
|
157
|
+
// gone away. But a picture in picture-in-picture is watched WHILE the tab is
|
|
158
|
+
// hidden, so hiding alone cannot mean "not watching".
|
|
159
|
+
//
|
|
160
|
+
// A page that says nothing is on screen, which is what every page meant
|
|
161
|
+
// before it could say otherwise.
|
|
162
|
+
this.onScreen = true;
|
|
163
|
+
this.inPictureInPicture = false;
|
|
164
|
+
// Seconds of film held ahead of the picture, as the page last said.
|
|
165
|
+
this.bufferedSeconds = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Where they are, in SECONDS of film, and nothing else.
|
|
170
|
+
*
|
|
171
|
+
* A segment number cannot live here: the picture and the soundtrack of one
|
|
172
|
+
* film are cut independently and into different numbers of pieces — 454
|
|
173
|
+
* against 401 on the field file of 2026-09-05 — so piece 48 of one is not the
|
|
174
|
+
* same moment as piece 48 of the other. Whoever holds a cut grid turns these
|
|
175
|
+
* seconds into their own numbers.
|
|
176
|
+
*
|
|
177
|
+
* @param {number} seconds
|
|
178
|
+
* @param {number} [now]
|
|
179
|
+
*/
|
|
180
|
+
moveTo(seconds, now = Date.now()) {
|
|
181
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.position = { seconds, at: now, seeked: seconds };
|
|
185
|
+
this.lastSeenAt = now;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Everything a viewer says about itself, in one statement.
|
|
190
|
+
*
|
|
191
|
+
* The page sends four things together — how fast its link measured, how much
|
|
192
|
+
* film it holds, where the picture is, and whether the picture is moving —
|
|
193
|
+
* and all four are facts about this viewer. Taking them apart and assigning
|
|
194
|
+
* them one by one somewhere else is how they came to be spread over five
|
|
195
|
+
* places, two of them on a session shared with other people.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} report
|
|
198
|
+
* @param {number} report.linkMbps
|
|
199
|
+
* @param {number} report.bufferedAheadSec
|
|
200
|
+
* @param {number | null} [report.positionSeconds] - Null from a page that
|
|
201
|
+
* does not say; then the position stands as it was.
|
|
202
|
+
* @param {boolean} [report.playing] - Absent from a page that does not say;
|
|
203
|
+
* then the viewer counts as playing, which is what every page meant before
|
|
204
|
+
* it could say otherwise.
|
|
205
|
+
* @param {boolean} [report.onScreen] - Whether the page is visible, or the
|
|
206
|
+
* picture is in picture-in-picture. Absent means on screen.
|
|
207
|
+
* @param {boolean} [report.inPictureInPicture] - Whether the picture has been
|
|
208
|
+
* pulled out of the page, which is watching it with the tab hidden.
|
|
209
|
+
* @param {number} [now]
|
|
210
|
+
*/
|
|
211
|
+
report(
|
|
212
|
+
{
|
|
213
|
+
linkMbps,
|
|
214
|
+
bufferedAheadSec,
|
|
215
|
+
positionSeconds = null,
|
|
216
|
+
playing,
|
|
217
|
+
onScreen,
|
|
218
|
+
inPictureInPicture
|
|
219
|
+
},
|
|
220
|
+
now = Date.now()
|
|
221
|
+
) {
|
|
222
|
+
this.netReport = {
|
|
223
|
+
linkMbps,
|
|
224
|
+
bufferedAheadSec,
|
|
225
|
+
positionSeconds:
|
|
226
|
+
Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
|
|
227
|
+
at: now
|
|
228
|
+
};
|
|
229
|
+
this.bufferedSeconds = bufferedAheadSec;
|
|
230
|
+
this.playing = playing === undefined ? true : Boolean(playing);
|
|
231
|
+
this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
|
|
232
|
+
this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
|
|
233
|
+
if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
|
|
234
|
+
this.moveTo(/** @type {number} */ (positionSeconds), now);
|
|
235
|
+
}
|
|
236
|
+
this.seen(now);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* When this viewer runs out of what they hold, in milliseconds.
|
|
241
|
+
*
|
|
242
|
+
* Film is consumed at one second per second while the picture moves, so the
|
|
243
|
+
* moment they run dry is now plus what they hold. Stopped, they consume
|
|
244
|
+
* nothing and there is no such moment — which is why a pause needs no rule of
|
|
245
|
+
* its own anywhere: it falls out of this as an absent deadline.
|
|
246
|
+
*
|
|
247
|
+
* @param {number} [now]
|
|
248
|
+
* @returns {number | null}
|
|
249
|
+
*/
|
|
250
|
+
deadlineAt(now = Date.now()) {
|
|
251
|
+
if (!this.playing) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
|
|
255
|
+
return now + held * 1000;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Whether this viewer is still watching.
|
|
260
|
+
*
|
|
261
|
+
* Two ways to stop being present, and neither of them is "asked for nothing
|
|
262
|
+
* recently". Something must have SAID they are gone, or nothing at all must
|
|
263
|
+
* have been heard from them for longer than any silence a watching viewer can
|
|
264
|
+
* produce.
|
|
265
|
+
*
|
|
266
|
+
* The second half exists only because the connection cannot always say: an
|
|
267
|
+
* `onClosed` on a data channel does not always come, and a transport that is
|
|
268
|
+
* not a data channel at all may have nothing to say. It is a backstop for a
|
|
269
|
+
* missing statement, not the ordinary way a viewer leaves.
|
|
270
|
+
*
|
|
271
|
+
* @param {number} now
|
|
272
|
+
* @param {number} staleAfterMs - Longer than any silence a watching viewer
|
|
273
|
+
* can produce. Derived from the cushion, never chosen here.
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*/
|
|
276
|
+
/**
|
|
277
|
+
* Whether this viewer is CONSUMING film.
|
|
278
|
+
*
|
|
279
|
+
* Two ways of not consuming, and neither is absence: the picture is stopped,
|
|
280
|
+
* or the page is not on screen. Both mean nothing in front of them ever falls
|
|
281
|
+
* due, so the work goes to whoever is watching — and both leave them a place
|
|
282
|
+
* in the priority map, because they are still there and will want it again.
|
|
283
|
+
*
|
|
284
|
+
* Picture-in-picture is watching with the tab hidden, and the page folds that
|
|
285
|
+
* into `onScreen` before it says it, so it needs no case of its own here.
|
|
286
|
+
*
|
|
287
|
+
* @returns {boolean}
|
|
288
|
+
*/
|
|
289
|
+
consumesFilm() {
|
|
290
|
+
return this.playing !== false && this.onScreen !== false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
isPresent(now, staleAfterMs) {
|
|
294
|
+
if (this.gone) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
return now - this.lastSeenAt <= staleAfterMs;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Note that this viewer has been heard from.
|
|
302
|
+
*
|
|
303
|
+
* @param {number} [now]
|
|
304
|
+
* @returns {void}
|
|
305
|
+
*/
|
|
306
|
+
seen(now = Date.now()) {
|
|
307
|
+
this.lastSeenAt = now;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Where this viewer is, in seconds, or null when they have somehow never been
|
|
312
|
+
* placed — which for a viewer created through `Viewers.of` cannot happen.
|
|
313
|
+
*
|
|
314
|
+
* @returns {number | null}
|
|
315
|
+
*/
|
|
316
|
+
positionSeconds() {
|
|
317
|
+
if (this.position === null) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
return Number.isFinite(this.position.seeked) ? this.position.seeked : this.position.seconds;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The viewers of one session, made on first use.
|
|
326
|
+
*
|
|
327
|
+
* @param {object} session
|
|
328
|
+
* @returns {Map<string, Viewer>}
|
|
329
|
+
*/
|
|
330
|
+
export function viewersOf(session) {
|
|
331
|
+
if (!(session.viewers instanceof Map)) {
|
|
332
|
+
session.viewers = new Map();
|
|
333
|
+
}
|
|
334
|
+
return session.viewers;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The viewer with this id, made if this session has not met them before.
|
|
339
|
+
*
|
|
340
|
+
* Use `Viewers.of` instead wherever a registry is at hand: this makes ONE
|
|
341
|
+
* viewer per session, so the same person watching a picture, a quality step and
|
|
342
|
+
* a soundtrack is three objects, and `outputs` — a fact about the person — is
|
|
343
|
+
* then three sets that nothing keeps in step. It is kept for a session assembled
|
|
344
|
+
* by hand in a test that has no registry.
|
|
345
|
+
*
|
|
346
|
+
* @param {object} session
|
|
347
|
+
* @param {string} consumerId
|
|
348
|
+
* @returns {Viewer}
|
|
349
|
+
*/
|
|
350
|
+
export function viewerOf(session, consumerId) {
|
|
351
|
+
const viewers = viewersOf(session);
|
|
352
|
+
let viewer = viewers.get(consumerId);
|
|
353
|
+
if (!viewer) {
|
|
354
|
+
viewer = new Viewer(consumerId);
|
|
355
|
+
viewers.set(consumerId, viewer);
|
|
356
|
+
}
|
|
357
|
+
viewer.outputs.add(session.id);
|
|
358
|
+
return viewer;
|
|
359
|
+
}
|