@torrent-tv/proxy 2.80.12 → 2.80.14

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.
@@ -1,297 +1,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
- // Whether the picture is moving. A viewer who has stopped it consumes
129
- // nothing, so nothing in front of them ever becomes due — they have no
130
- // deadline at all, and the work goes to whoever is watching. The page knows
131
- // this exactly and says it outright; inferring it from a position that has
132
- // not moved takes two reports and lies whenever a browser holding a full
133
- // cushion goes quiet between segments, which it does.
134
- this.playing = true;
135
- // Seconds of film held ahead of the picture, as the page last said.
136
- this.bufferedSeconds = null;
137
- }
138
-
139
- /**
140
- * Where they are, in SECONDS of film, and nothing else.
141
- *
142
- * A segment number cannot live here: the picture and the soundtrack of one
143
- * film are cut independently and into different numbers of pieces 454
144
- * against 401 on the field file of 2026-09-05 — so piece 48 of one is not the
145
- * same moment as piece 48 of the other. Whoever holds a cut grid turns these
146
- * seconds into their own numbers.
147
- *
148
- * @param {number} seconds
149
- * @param {number} [now]
150
- */
151
- moveTo(seconds, now = Date.now()) {
152
- if (!Number.isFinite(seconds) || seconds < 0) {
153
- return;
154
- }
155
- this.position = { seconds, at: now, seeked: seconds };
156
- this.lastSeenAt = now;
157
- }
158
-
159
- /**
160
- * Everything a viewer says about itself, in one statement.
161
- *
162
- * The page sends four things together — how fast its link measured, how much
163
- * film it holds, where the picture is, and whether the picture is moving —
164
- * and all four are facts about this viewer. Taking them apart and assigning
165
- * them one by one somewhere else is how they came to be spread over five
166
- * places, two of them on a session shared with other people.
167
- *
168
- * @param {object} report
169
- * @param {number} report.linkMbps
170
- * @param {number} report.bufferedAheadSec
171
- * @param {number | null} [report.positionSeconds] - Null from a page that
172
- * does not say; then the position stands as it was.
173
- * @param {boolean} [report.playing] - Absent from a page that does not say;
174
- * then the viewer counts as playing, which is what every page meant before
175
- * it could say otherwise.
176
- * @param {number} [now]
177
- */
178
- report({ linkMbps, bufferedAheadSec, positionSeconds = null, playing }, now = Date.now()) {
179
- this.netReport = {
180
- linkMbps,
181
- bufferedAheadSec,
182
- positionSeconds:
183
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
184
- at: now
185
- };
186
- this.bufferedSeconds = bufferedAheadSec;
187
- this.playing = playing === undefined ? true : Boolean(playing);
188
- if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
189
- this.moveTo(/** @type {number} */ (positionSeconds), now);
190
- }
191
- this.seen(now);
192
- }
193
-
194
- /**
195
- * When this viewer runs out of what they hold, in milliseconds.
196
- *
197
- * Film is consumed at one second per second while the picture moves, so the
198
- * moment they run dry is now plus what they hold. Stopped, they consume
199
- * nothing and there is no such moment — which is why a pause needs no rule of
200
- * its own anywhere: it falls out of this as an absent deadline.
201
- *
202
- * @param {number} [now]
203
- * @returns {number | null}
204
- */
205
- deadlineAt(now = Date.now()) {
206
- if (!this.playing) {
207
- return null;
208
- }
209
- const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
210
- return now + held * 1000;
211
- }
212
-
213
- /**
214
- * Whether this viewer is still watching.
215
- *
216
- * Two ways to stop being present, and neither of them is "asked for nothing
217
- * recently". Something must have SAID they are gone, or nothing at all must
218
- * have been heard from them for longer than any silence a watching viewer can
219
- * produce.
220
- *
221
- * The second half exists only because the connection cannot always say: an
222
- * `onClosed` on a data channel does not always come, and a transport that is
223
- * not a data channel at all may have nothing to say. It is a backstop for a
224
- * missing statement, not the ordinary way a viewer leaves.
225
- *
226
- * @param {number} now
227
- * @param {number} staleAfterMs - Longer than any silence a watching viewer
228
- * can produce. Derived from the cushion, never chosen here.
229
- * @returns {boolean}
230
- */
231
- isPresent(now, staleAfterMs) {
232
- if (this.gone) {
233
- return false;
234
- }
235
- return now - this.lastSeenAt <= staleAfterMs;
236
- }
237
-
238
- /**
239
- * Note that this viewer has been heard from.
240
- *
241
- * @param {number} [now]
242
- * @returns {void}
243
- */
244
- seen(now = Date.now()) {
245
- this.lastSeenAt = now;
246
- }
247
-
248
- /**
249
- * Where this viewer is, in seconds, or null when they have somehow never been
250
- * placed — which for a viewer created through `Viewers.of` cannot happen.
251
- *
252
- * @returns {number | null}
253
- */
254
- positionSeconds() {
255
- if (this.position === null) {
256
- return null;
257
- }
258
- return Number.isFinite(this.position.seeked) ? this.position.seeked : this.position.seconds;
259
- }
260
- }
261
-
262
- /**
263
- * The viewers of one session, made on first use.
264
- *
265
- * @param {object} session
266
- * @returns {Map<string, Viewer>}
267
- */
268
- export function viewersOf(session) {
269
- if (!(session.viewers instanceof Map)) {
270
- session.viewers = new Map();
271
- }
272
- return session.viewers;
273
- }
274
-
275
- /**
276
- * The viewer with this id, made if this session has not met them before.
277
- *
278
- * Use `Viewers.of` instead wherever a registry is at hand: this makes ONE
279
- * viewer per session, so the same person watching a picture, a quality step and
280
- * a soundtrack is three objects, and `outputs` — a fact about the person — is
281
- * then three sets that nothing keeps in step. It is kept for a session assembled
282
- * by hand in a test that has no registry.
283
- *
284
- * @param {object} session
285
- * @param {string} consumerId
286
- * @returns {Viewer}
287
- */
288
- export function viewerOf(session, consumerId) {
289
- const viewers = viewersOf(session);
290
- let viewer = viewers.get(consumerId);
291
- if (!viewer) {
292
- viewer = new Viewer(consumerId);
293
- viewers.set(consumerId, viewer);
294
- }
295
- viewer.outputs.add(session.id);
296
- return viewer;
297
- }
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
+ // Whether the picture is moving. A viewer who has stopped it consumes
129
+ // nothing, so nothing in front of them ever becomes due — they have no
130
+ // deadline at all, and the work goes to whoever is watching. The page knows
131
+ // this exactly and says it outright; inferring it from a position that has
132
+ // not moved takes two reports and lies whenever a browser holding a full
133
+ // cushion goes quiet between segments, which it does.
134
+ this.playing = true;
135
+ // Whether the page carrying this viewer is ON SCREEN, and whether the
136
+ // picture has been pulled out of it.
137
+ //
138
+ // Two facts, not one, and the second is why the first is not enough: a
139
+ // hidden tab has its timers throttled by the browser — 800 ms of event-loop
140
+ // lag measured in the field so it asks for nothing and looks exactly like
141
+ // a viewer holding a full cushion. Delivery stood still for the last six
142
+ // minutes of the session of 2026-09-08 and nothing anywhere said the tab had
143
+ // gone away. But a picture in picture-in-picture is watched WHILE the tab is
144
+ // hidden, so hiding alone cannot mean "not watching".
145
+ //
146
+ // A page that says nothing is on screen, which is what every page meant
147
+ // before it could say otherwise.
148
+ this.onScreen = true;
149
+ this.inPictureInPicture = false;
150
+ // Seconds of film held ahead of the picture, as the page last said.
151
+ this.bufferedSeconds = null;
152
+ }
153
+
154
+ /**
155
+ * Where they are, in SECONDS of film, and nothing else.
156
+ *
157
+ * A segment number cannot live here: the picture and the soundtrack of one
158
+ * film are cut independently and into different numbers of pieces — 454
159
+ * against 401 on the field file of 2026-09-05 — so piece 48 of one is not the
160
+ * same moment as piece 48 of the other. Whoever holds a cut grid turns these
161
+ * seconds into their own numbers.
162
+ *
163
+ * @param {number} seconds
164
+ * @param {number} [now]
165
+ */
166
+ moveTo(seconds, now = Date.now()) {
167
+ if (!Number.isFinite(seconds) || seconds < 0) {
168
+ return;
169
+ }
170
+ this.position = { seconds, at: now, seeked: seconds };
171
+ this.lastSeenAt = now;
172
+ }
173
+
174
+ /**
175
+ * Everything a viewer says about itself, in one statement.
176
+ *
177
+ * The page sends four things together — how fast its link measured, how much
178
+ * film it holds, where the picture is, and whether the picture is moving —
179
+ * and all four are facts about this viewer. Taking them apart and assigning
180
+ * them one by one somewhere else is how they came to be spread over five
181
+ * places, two of them on a session shared with other people.
182
+ *
183
+ * @param {object} report
184
+ * @param {number} report.linkMbps
185
+ * @param {number} report.bufferedAheadSec
186
+ * @param {number | null} [report.positionSeconds] - Null from a page that
187
+ * does not say; then the position stands as it was.
188
+ * @param {boolean} [report.playing] - Absent from a page that does not say;
189
+ * then the viewer counts as playing, which is what every page meant before
190
+ * it could say otherwise.
191
+ * @param {boolean} [report.onScreen] - Whether the page is visible, or the
192
+ * picture is in picture-in-picture. Absent means on screen.
193
+ * @param {boolean} [report.inPictureInPicture] - Whether the picture has been
194
+ * pulled out of the page, which is watching it with the tab hidden.
195
+ * @param {number} [now]
196
+ */
197
+ report(
198
+ {
199
+ linkMbps,
200
+ bufferedAheadSec,
201
+ positionSeconds = null,
202
+ playing,
203
+ onScreen,
204
+ inPictureInPicture
205
+ },
206
+ now = Date.now()
207
+ ) {
208
+ this.netReport = {
209
+ linkMbps,
210
+ bufferedAheadSec,
211
+ positionSeconds:
212
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
213
+ at: now
214
+ };
215
+ this.bufferedSeconds = bufferedAheadSec;
216
+ this.playing = playing === undefined ? true : Boolean(playing);
217
+ this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
218
+ this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
219
+ if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
220
+ this.moveTo(/** @type {number} */ (positionSeconds), now);
221
+ }
222
+ this.seen(now);
223
+ }
224
+
225
+ /**
226
+ * When this viewer runs out of what they hold, in milliseconds.
227
+ *
228
+ * Film is consumed at one second per second while the picture moves, so the
229
+ * moment they run dry is now plus what they hold. Stopped, they consume
230
+ * nothing and there is no such moment — which is why a pause needs no rule of
231
+ * its own anywhere: it falls out of this as an absent deadline.
232
+ *
233
+ * @param {number} [now]
234
+ * @returns {number | null}
235
+ */
236
+ deadlineAt(now = Date.now()) {
237
+ if (!this.playing) {
238
+ return null;
239
+ }
240
+ const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
241
+ return now + held * 1000;
242
+ }
243
+
244
+ /**
245
+ * Whether this viewer is still watching.
246
+ *
247
+ * Two ways to stop being present, and neither of them is "asked for nothing
248
+ * recently". Something must have SAID they are gone, or nothing at all must
249
+ * have been heard from them for longer than any silence a watching viewer can
250
+ * produce.
251
+ *
252
+ * The second half exists only because the connection cannot always say: an
253
+ * `onClosed` on a data channel does not always come, and a transport that is
254
+ * not a data channel at all may have nothing to say. It is a backstop for a
255
+ * missing statement, not the ordinary way a viewer leaves.
256
+ *
257
+ * @param {number} now
258
+ * @param {number} staleAfterMs - Longer than any silence a watching viewer
259
+ * can produce. Derived from the cushion, never chosen here.
260
+ * @returns {boolean}
261
+ */
262
+ /**
263
+ * Whether this viewer is CONSUMING film.
264
+ *
265
+ * Two ways of not consuming, and neither is absence: the picture is stopped,
266
+ * or the page is not on screen. Both mean nothing in front of them ever falls
267
+ * due, so the work goes to whoever is watching — and both leave them a place
268
+ * in the priority map, because they are still there and will want it again.
269
+ *
270
+ * Picture-in-picture is watching with the tab hidden, and the page folds that
271
+ * into `onScreen` before it says it, so it needs no case of its own here.
272
+ *
273
+ * @returns {boolean}
274
+ */
275
+ consumesFilm() {
276
+ return this.playing !== false && this.onScreen !== false;
277
+ }
278
+
279
+ isPresent(now, staleAfterMs) {
280
+ if (this.gone) {
281
+ return false;
282
+ }
283
+ return now - this.lastSeenAt <= staleAfterMs;
284
+ }
285
+
286
+ /**
287
+ * Note that this viewer has been heard from.
288
+ *
289
+ * @param {number} [now]
290
+ * @returns {void}
291
+ */
292
+ seen(now = Date.now()) {
293
+ this.lastSeenAt = now;
294
+ }
295
+
296
+ /**
297
+ * Where this viewer is, in seconds, or null when they have somehow never been
298
+ * placed — which for a viewer created through `Viewers.of` cannot happen.
299
+ *
300
+ * @returns {number | null}
301
+ */
302
+ positionSeconds() {
303
+ if (this.position === null) {
304
+ return null;
305
+ }
306
+ return Number.isFinite(this.position.seeked) ? this.position.seeked : this.position.seconds;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * The viewers of one session, made on first use.
312
+ *
313
+ * @param {object} session
314
+ * @returns {Map<string, Viewer>}
315
+ */
316
+ export function viewersOf(session) {
317
+ if (!(session.viewers instanceof Map)) {
318
+ session.viewers = new Map();
319
+ }
320
+ return session.viewers;
321
+ }
322
+
323
+ /**
324
+ * The viewer with this id, made if this session has not met them before.
325
+ *
326
+ * Use `Viewers.of` instead wherever a registry is at hand: this makes ONE
327
+ * viewer per session, so the same person watching a picture, a quality step and
328
+ * a soundtrack is three objects, and `outputs` — a fact about the person — is
329
+ * then three sets that nothing keeps in step. It is kept for a session assembled
330
+ * by hand in a test that has no registry.
331
+ *
332
+ * @param {object} session
333
+ * @param {string} consumerId
334
+ * @returns {Viewer}
335
+ */
336
+ export function viewerOf(session, consumerId) {
337
+ const viewers = viewersOf(session);
338
+ let viewer = viewers.get(consumerId);
339
+ if (!viewer) {
340
+ viewer = new Viewer(consumerId);
341
+ viewers.set(consumerId, viewer);
342
+ }
343
+ viewer.outputs.add(session.id);
344
+ return viewer;
345
+ }