@torrent-tv/proxy 2.84.0 → 2.85.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.
@@ -23,7 +23,6 @@ import { speedFromReadings } from "./encoder-readings.js";
23
23
  import { availableShareFrom } from "./available-share.js";
24
24
  import { contentionPenalty } from "./encode/contention.js";
25
25
  import { minimumBufferFrom } from "./supply-margin.js";
26
- import { earliestUrgentSecond, runsOf } from "./priority/PriorityMap.js";
27
26
  import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
28
27
  import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
29
28
  import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
@@ -89,6 +88,7 @@ import {
89
88
  // — did not move when the code did.
90
89
  export { ffmpegSeconds, onKeyframeGridFor, seekLandingOffsetFor, segmentCutTimesFrom };
91
90
  import { viewersOf } from "./viewer/Viewer.js";
91
+ import { activeOutputFor } from "./viewer/active-output.js";
92
92
  import { earliestViewerSecondsOn, viewerSecondsOn, viewerSegmentsOn } from "./viewer/positions.js";
93
93
  import { Viewers } from "./viewer/Viewers.js";
94
94
  import { LiveOutputs } from "./output/LiveOutputs.js";
@@ -524,7 +524,6 @@ const BUDGET_DOWNLOAD_OK_FACTOR = 1.0;
524
524
  // instead. Which of the two, and whether a viewer's own pick may be moved at
525
525
  // all, is decided where the viewer's choice lives: in the browser, which
526
526
  // honours the request only in automatic mode.
527
- const LINK_REPORT_FRESH_MS = 30_000;
528
527
  // Usable share of the reported link (protocol overhead + measurement noise).
529
528
  const LINK_SAFETY = 0.8;
530
529
  // Deficit must persist this long before acting (absorbs one slow segment).
@@ -3058,54 +3057,6 @@ export class HlsSessionManager {
3058
3057
  *
3059
3058
  * @returns {Promise<void>}
3060
3059
  */
3061
- /**
3062
- * Record the latest viewer link report for a session (adaptive bitrate).
3063
- * Returns false for an unknown/disposed session.
3064
- *
3065
- * Kept per viewer. A browser that does not say who it is lands under one
3066
- * shared key, which is exactly the old behaviour for that browser and no
3067
- * worse: with one viewer the two are the same thing.
3068
- *
3069
- * @param {string} sessionId
3070
- * @param {{ linkMbps: number, bufferedAheadSec: number, consumerId?: string, positionSeconds?: number }} report
3071
- * @returns {boolean}
3072
- */
3073
- recordNetReport(sessionId, { linkMbps, bufferedAheadSec, consumerId, positionSeconds, playing, onScreen, inPictureInPicture }) {
3074
- const named = this.sessionsById.get(sessionId);
3075
- if (!named || named.state === "disposed") {
3076
- return false;
3077
- }
3078
- // A throughput that is not a positive finite number is not a measurement,
3079
- // and it reaches an encoder's `-maxrate` from here.
3080
- if (!Number.isFinite(linkMbps) || !(linkMbps > 0) || !Number.isFinite(bufferedAheadSec)) {
3081
- return false;
3082
- }
3083
- // The link carries the stream on screen, so the report belongs to the
3084
- // variant producing it — that is the encoder whose bitrate it can bound.
3085
- // Whose screen, is the reporter's own: with two viewers on two rungs, the
3086
- // report of one of them says nothing about the other's encoder.
3087
- const session = this.#activeVariant(named, typeof consumerId === "string" ? consumerId : "");
3088
- const now = Date.now();
3089
- this.viewers
3090
- .of(session, typeof consumerId === "string" && consumerId.length > 0 ? consumerId : "")
3091
- .report({ linkMbps, bufferedAheadSec, positionSeconds, playing, onScreen, inPictureInPicture }, now);
3092
- // A stale reading must not go on deciding for the viewers still here: a
3093
- // report describes a link at a moment, and a viewer who seeked since then
3094
- // is somewhere else entirely.
3095
- //
3096
- // Only the READING expires. Whether the person is still watching is a
3097
- // different question with its own answer — their connection — and a viewer
3098
- // who has simply stopped reporting is not thereby gone. Answering both from
3099
- // this one place is what stopped a soundtrack's encoder on 2026-09-05.
3100
- for (const viewer of viewersOf(session).values()) {
3101
- const report = viewer.netReport;
3102
- if (report !== null && now - report.at > LINK_REPORT_FRESH_MS) {
3103
- viewer.netReport = null;
3104
- }
3105
- }
3106
- return true;
3107
- }
3108
-
3109
3060
  /**
3110
3061
  * Record where one viewer of this session is, and answer with the furthest
3111
3062
  * any of them has reached.
@@ -3153,8 +3104,8 @@ export class HlsSessionManager {
3153
3104
  #worstNetReport(session, now) {
3154
3105
  let worst = null;
3155
3106
  for (const viewer of viewersOf(session).values()) {
3156
- const report = viewer.netReport;
3157
- if (report === null || now - report.at > LINK_REPORT_FRESH_MS) {
3107
+ const report = viewer.linkReading(now);
3108
+ if (report === null) {
3158
3109
  continue;
3159
3110
  }
3160
3111
  if (worst === null) {
@@ -3204,7 +3155,7 @@ export class HlsSessionManager {
3204
3155
  // run and its own position, and that is exactly the pair this report exists
3205
3156
  // to tell apart. Answering an audio report from the picture's records would
3206
3157
  // state, confidently, something about the wrong stream.
3207
- const onScreen = this.#activeVariant(named);
3158
+ const onScreen = activeOutputFor({ base: named, sessions: this.sessionsById, viewers: this.viewers });
3208
3159
  const session = track === "audio"
3209
3160
  ? ([...this.liveOutputs.familyOf(onScreen)].find((member) => member.audioOnly === true) ?? onScreen)
3210
3161
  : onScreen;
@@ -3293,7 +3244,7 @@ export class HlsSessionManager {
3293
3244
  */
3294
3245
  async #checkLinkBudget(session, now) {
3295
3246
  const report = this.#worstNetReport(session, now);
3296
- if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
3247
+ if (!report) {
3297
3248
  session.linkSlowSince = 0; // no fresh data — old clients / stopped reporter
3298
3249
  return false;
3299
3250
  }
@@ -4684,7 +4635,7 @@ export class HlsSessionManager {
4684
4635
  }
4685
4636
  return false;
4686
4637
  }
4687
- const playing = this.liveOutputs.variantHeightOf(this.#activeVariant(base));
4638
+ const playing = this.liveOutputs.variantHeightOf(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }));
4688
4639
  if (height === playing) {
4689
4640
  return false;
4690
4641
  }
@@ -4783,7 +4734,7 @@ export class HlsSessionManager {
4783
4734
  return false;
4784
4735
  }
4785
4736
  const report = this.#worstNetReport(session, now);
4786
- if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4737
+ if (!report) {
4787
4738
  // Nothing fresh measures the link, so it has no opinion either way — the
4788
4739
  // same silence that stops #checkLinkBudget from acting.
4789
4740
  return true;
@@ -4811,7 +4762,7 @@ export class HlsSessionManager {
4811
4762
  // The SLOWEST link among the viewers: a step up has to be carried by all of
4812
4763
  // them, not by whichever reported last.
4813
4764
  const report = this.#worstNetReport(session, now);
4814
- if (!report || now - report.at > LINK_REPORT_FRESH_MS) {
4765
+ if (!report) {
4815
4766
  return true;
4816
4767
  }
4817
4768
  return report.linkMbps * LINK_SAFETY >= wantedMbps;
@@ -7185,7 +7136,7 @@ export class HlsSessionManager {
7185
7136
  if (!ask) {
7186
7137
  return 0;
7187
7138
  }
7188
- if (ask.height === this.liveOutputs.variantHeightOf(this.#activeVariant(base))) {
7139
+ if (ask.height === this.liveOutputs.variantHeightOf(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }))) {
7189
7140
  base.qualityAsk = null; // the viewer is there; nothing left to ask for
7190
7141
  return 0;
7191
7142
  }
@@ -7200,45 +7151,6 @@ export class HlsSessionManager {
7200
7151
  return ask.height;
7201
7152
  }
7202
7153
 
7203
- /**
7204
- * The variant of a session that the viewer is watching right now.
7205
- *
7206
- * Every request that names the base session — seek, progress, link report,
7207
- * release — means the stream the viewer has on screen, and after a quality
7208
- * change that is another session. The browser is not told about the swap: it
7209
- * holds one session id for the whole file, which is what keeps the switch out
7210
- * of the state machine on that side.
7211
- *
7212
- * Kept per viewer, because one picture is shared by everyone watching it and
7213
- * a quality step is a session of its own: with one answer for the session, a
7214
- * step taken by one viewer would move the other one's stream, and that
7215
- * viewer's next seek would be forwarded to a rung they never chose. The
7216
- * session's own field remains the answer for a viewer who cannot name
7217
- * themselves, and the last one anybody moved to.
7218
- *
7219
- * @param {HlsSession} base
7220
- * @param {string} [consumerId]
7221
- * @returns {HlsSession}
7222
- */
7223
- #activeVariant(base, consumerId = "") {
7224
- const named = consumerId ? base.viewers?.get(consumerId)?.activeVariantId ?? null : null;
7225
- const activeId = named ?? base.activeVariantId;
7226
- if (!activeId || activeId === base.id) {
7227
- return base;
7228
- }
7229
- const active = this.sessionsById.get(activeId);
7230
- if (!active || active.state === "disposed") {
7231
- if (named) {
7232
- this.viewers.of(base, consumerId).activeVariantId = null;
7233
- }
7234
- if (base.activeVariantId === activeId) {
7235
- base.activeVariantId = base.id;
7236
- }
7237
- return base;
7238
- }
7239
- return active;
7240
- }
7241
-
7242
7154
  /**
7243
7155
  * Every session of this family that a live viewer has on screen.
7244
7156
  *
@@ -7263,7 +7175,7 @@ export class HlsSessionManager {
7263
7175
  onScreen.add(viewer.activeVariantId);
7264
7176
  }
7265
7177
  if (onScreen.size === 0) {
7266
- onScreen.add(this.#activeVariant(base).id);
7178
+ onScreen.add(activeOutputFor({ base, sessions: this.sessionsById, viewers: this.viewers }).id);
7267
7179
  }
7268
7180
  return onScreen;
7269
7181
  }
@@ -7296,7 +7208,7 @@ export class HlsSessionManager {
7296
7208
  if (Number.isInteger(wantedIndex) && wantedIndex >= 0) {
7297
7209
  return this.#segmentStartTime(base, wantedIndex);
7298
7210
  }
7299
- return viewerSecondsOn(this.#activeVariant(base, consumerId));
7211
+ return viewerSecondsOn(activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers }));
7300
7212
  }
7301
7213
 
7302
7214
  /**
@@ -7377,7 +7289,7 @@ export class HlsSessionManager {
7377
7289
  * earliest. Reading it off a viewer put a person inside the encoding layer,
7378
7290
  * and then every question about WHICH person had to be answered a second time
7379
7291
  * here: the earliest of two, the one on this rung, the one who has not
7380
- * reported yet. `earliestUrgentSecond` answers it once, for everything.
7292
+ * reported yet.
7381
7293
  *
7382
7294
  * The apparatus this replaced is worth naming so it is not rebuilt: three
7383
7295
  * position sources ranked by priority, a function saying which had answered,
@@ -7901,7 +7813,7 @@ export class HlsSessionManager {
7901
7813
  // buys it an encoder, and both halves are said here: a warmed rung is one
7902
7814
  // this person is watching for as long as the warm-up lasts, which is why
7903
7815
  // two encoders run through it.
7904
- if (variant.id !== this.#activeVariant(base, consumerId).id) {
7816
+ if (variant.id !== activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers }).id) {
7905
7817
  this.viewers.of(variant, consumerId).moveTo(this.#segmentStartTime(base, index));
7906
7818
  this.planEncodersSoon();
7907
7819
  }
@@ -7926,7 +7838,7 @@ export class HlsSessionManager {
7926
7838
  * @returns {void}
7927
7839
  */
7928
7840
  #noteVariantActive(base, variant, wantedIndex = -1, consumerId = "") {
7929
- const previous = this.#activeVariant(base, consumerId);
7841
+ const previous = activeOutputFor({ base, consumerId, sessions: this.sessionsById, viewers: this.viewers });
7930
7842
  if (previous.id === variant.id) {
7931
7843
  // The rung on screen asking for more of itself, which it does every few
7932
7844
  // seconds. Nothing is being decided here — and deciding anything was the
@@ -9449,7 +9361,7 @@ export class HlsSessionManager {
9449
9361
  // change is another session. Touching the named one as well is what keeps
9450
9362
  // the family alive: only the ACTIVE variant gets segment requests, so
9451
9363
  // without this the base session would idle out from under its own variants.
9452
- const session = this.#activeVariant(named, consumerId);
9364
+ const session = activeOutputFor({ base: named, consumerId, sessions: this.sessionsById, viewers: this.viewers });
9453
9365
  session.lastAccessedAt = Date.now();
9454
9366
  const warmupTotalSeconds = this.startupWaitMs / 1000;
9455
9367
  const warmupElapsedSeconds = Math.max(0, (Date.now() - session.startedAt) / 1000);
@@ -223,13 +223,17 @@ export class PriorityOrchestrator {
223
223
  }
224
224
  const stated = {
225
225
  atSeconds: viewer.positionSeconds(now) ?? 0,
226
- // CONSUMING, not merely playing. A page that is not on screen has
227
- // its timers throttled and asks for nothing, which is exactly what
228
- // a viewer holding a full cushion looks like; the two are told
229
- // apart on the page and folded into one question here.
230
- playing: typeof viewer.consumesFilm === "function"
231
- ? viewer.consumesFilm()
232
- : viewer.playing !== false
226
+ // WATCHING OR WAITING, not merely playing. Three states reach this
227
+ // one question: a viewer whose picture is advancing, a viewer
228
+ // blocked on material we owe them, and a viewer who stopped it
229
+ // themselves. The first two want the film in front of them NOW and
230
+ // share the upper band; only the third can wait. A page that is not
231
+ // on screen has its timers throttled and asks for nothing, which is
232
+ // indistinguishable from a full cushion, so it is not consuming
233
+ // either — told apart on the page, folded into one question here.
234
+ playing: typeof viewer.wantsFilmNow === "function"
235
+ ? viewer.wantsFilmNow()
236
+ : viewer.playing === true || viewer.waiting === true
233
237
  };
234
238
  held.viewers.push(stated);
235
239
  if (this.#watchedBy(session, viewer)) {
@@ -40,6 +40,15 @@
40
40
  * question, and it reads a union of viewers rather than any one of them.
41
41
  */
42
42
 
43
+ /**
44
+ * How long a link measurement describes the link it was taken on.
45
+ *
46
+ * A reading older than this says nothing about now: the viewer may have seeked,
47
+ * changed network, or stopped. Thirty seconds is three of the page's own
48
+ * reporting intervals, so a live viewer always has a fresh one.
49
+ */
50
+ export const LINK_READING_FRESH_MS = 30_000;
51
+
43
52
  export class Viewer {
44
53
  /**
45
54
  * @param {string} id - The consumer id the browser sends with every request
@@ -149,13 +158,35 @@ export class Viewer {
149
158
  * @type {Set<string>}
150
159
  */
151
160
  this.wantsCuesFor = new Set();
152
- // Whether the picture is moving. A viewer who has stopped it consumes
153
- // nothing, so nothing in front of them ever becomes due — they have no
154
- // deadline at all, and the work goes to whoever is watching. The page knows
155
- // this exactly and says it outright; inferring it from a position that has
156
- // not moved takes two reports and lies whenever a browser holding a full
157
- // cushion goes quiet between segments, which it does.
158
- this.playing = true;
161
+ // WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
162
+ // BLOCKED ON US. Two booleans, because there are three states and one
163
+ // boolean cannot hold them:
164
+ //
165
+ // playing the picture advances, a second of film per second;
166
+ // waiting it does not advance because we have not delivered;
167
+ // neither the viewer stopped it themselves.
168
+ //
169
+ // Collapsed into one, the middle state read as the third: a viewer frozen
170
+ // for want of a segment counted as somebody who had chosen to stop, so
171
+ // nothing in front of them fell due and the work went to whoever was
172
+ // playing. That is backwards — a viewer waiting on us is the most urgent
173
+ // there is. The page tells the two apart exactly (its element says whether
174
+ // the picture advances, and the component owns the viewer's own pause) and
175
+ // states both.
176
+ //
177
+ // `playing` STARTS FALSE, and that is the whole of the fault of
178
+ // 2026-09-14. It used to start true, and the position is carried forward by
179
+ // the clock at that rate, so a viewer who had never said anything walked
180
+ // 146 seconds into a film they had not begun: the tab was hidden, the page
181
+ // deliberately held start-up until it was shown, and this end assumed the
182
+ // film was running the entire time. A statement about somebody else's
183
+ // machine is theirs to make. Until they make it the rate is zero.
184
+ //
185
+ // `waiting` STARTS TRUE, which is what a viewer arriving is: they have
186
+ // asked for a film and hold none of it. So their work is made first from
187
+ // the instant they arrive, without the position ever being invented.
188
+ this.playing = false;
189
+ this.waiting = true;
159
190
  // Whether the page carrying this viewer is ON SCREEN, and whether the
160
191
  // picture has been pulled out of it.
161
192
  //
@@ -171,7 +202,9 @@ export class Viewer {
171
202
  // before it could say otherwise.
172
203
  this.onScreen = true;
173
204
  this.inPictureInPicture = false;
174
- // Seconds of film held ahead of the picture, as the page last said.
205
+ // Seconds of film held ahead of the picture, as the page last said. It is
206
+ // measured at the moment the position beside it was, which is what makes it
207
+ // the bound on carrying that position forward — see `positionSeconds`.
175
208
  this.bufferedSeconds = null;
176
209
  }
177
210
 
@@ -192,6 +225,13 @@ export class Viewer {
192
225
  return;
193
226
  }
194
227
  this.position = { seconds, at: now };
228
+ // WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
229
+ // ARRIVED AT. A seek empties the buffer by construction — the player
230
+ // discards what it holds and fetches from the new position — so carrying
231
+ // the old cushion over would license the position to run forward again from
232
+ // material that no longer exists. Zero is the truthful floor until they say
233
+ // otherwise, and a viewer who has just seeked says so within a tick.
234
+ this.bufferedSeconds = 0;
195
235
  this.lastSeenAt = now;
196
236
  }
197
237
 
@@ -205,13 +245,18 @@ export class Viewer {
205
245
  * places, two of them on a session shared with other people.
206
246
  *
207
247
  * @param {object} report
208
- * @param {number} report.linkMbps
248
+ * @param {number} [report.linkMbps] - What their link last measured. Absent
249
+ * until something measurable has crossed it, and then the rest of the
250
+ * report still stands: this is a statement about a viewer, not about a link.
209
251
  * @param {number} report.bufferedAheadSec
210
252
  * @param {number | null} [report.positionSeconds] - Null from a page that
211
253
  * does not say; then the position stands as it was.
212
- * @param {boolean} [report.playing] - Absent from a page that does not say;
213
- * then the viewer counts as playing, which is what every page meant before
214
- * it could say otherwise.
254
+ * @param {boolean} [report.playing] - Whether the picture is advancing.
255
+ * Absent means it was not stated, and nothing may be assumed about somebody
256
+ * else's machine: the rate is then zero.
257
+ * @param {boolean} [report.waiting] - Whether this viewer is blocked on
258
+ * material we owe them. Absent from a page one release behind; then it is
259
+ * derived from the two facts that page does state.
215
260
  * @param {boolean} [report.onScreen] - Whether the page is visible, or the
216
261
  * picture is in picture-in-picture. Absent means on screen.
217
262
  * @param {boolean} [report.inPictureInPicture] - Whether the picture has been
@@ -224,47 +269,49 @@ export class Viewer {
224
269
  bufferedAheadSec,
225
270
  positionSeconds = null,
226
271
  playing,
272
+ waiting,
227
273
  onScreen,
228
274
  inPictureInPicture
229
275
  },
230
276
  now = Date.now()
231
277
  ) {
232
- this.netReport = {
233
- linkMbps,
234
- bufferedAheadSec,
235
- positionSeconds:
236
- Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
237
- at: now
238
- };
239
- this.bufferedSeconds = bufferedAheadSec;
240
- this.playing = playing === undefined ? true : Boolean(playing);
241
- this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
242
- this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
278
+ const held = Number.isFinite(bufferedAheadSec) && bufferedAheadSec > 0 ? bufferedAheadSec : 0;
279
+ // A LINK FIGURE IS ONE FIELD OF THIS REPORT AND NOT ITS TICKET. A page that
280
+ // has transferred nothing measurable has nothing to say about its link and
281
+ // everything to say about its viewer, and that is the cold open exactly —
282
+ // the moment the position matters most. Held as a precondition, in three
283
+ // places at once, it silenced every statement of the session of 2026-09-14.
284
+ if (Number.isFinite(linkMbps) && linkMbps > 0) {
285
+ this.netReport = {
286
+ linkMbps,
287
+ bufferedAheadSec: held,
288
+ positionSeconds:
289
+ Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : null,
290
+ at: now
291
+ };
292
+ }
293
+ // The position first, then the cushion: `moveTo` clears what was held at
294
+ // wherever they were before, and this report's own figure is measured at
295
+ // the position this report states.
243
296
  if (Number.isFinite(positionSeconds) && positionSeconds >= 0) {
244
297
  this.moveTo(/** @type {number} */ (positionSeconds), now);
245
298
  }
299
+ this.bufferedSeconds = held;
300
+ this.playing = playing === true;
301
+ // A page that states `playing` and not `waiting` is one release behind this
302
+ // proxy, which is the ordinary state of a rolling pool — the proxy ships
303
+ // first by rule. It says `false` for both a viewer who stopped the picture
304
+ // and a viewer starved of material, so the two are told apart by the one
305
+ // quantity it does state: a stopped viewer holding film chose to stop, a
306
+ // stopped viewer holding nothing is waiting on us. It errs toward making
307
+ // material, which is the direction that costs an encoder rather than a
308
+ // viewer.
309
+ this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
310
+ this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
311
+ this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
246
312
  this.seen(now);
247
313
  }
248
314
 
249
- /**
250
- * When this viewer runs out of what they hold, in milliseconds.
251
- *
252
- * Film is consumed at one second per second while the picture moves, so the
253
- * moment they run dry is now plus what they hold. Stopped, they consume
254
- * nothing and there is no such moment — which is why a pause needs no rule of
255
- * its own anywhere: it falls out of this as an absent deadline.
256
- *
257
- * @param {number} [now]
258
- * @returns {number | null}
259
- */
260
- deadlineAt(now = Date.now()) {
261
- if (!this.playing) {
262
- return null;
263
- }
264
- const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
265
- return now + held * 1000;
266
- }
267
-
268
315
  /**
269
316
  * Whether this viewer is here.
270
317
  *
@@ -284,20 +331,53 @@ export class Viewer {
284
331
  * @returns {boolean}
285
332
  */
286
333
  /**
287
- * Whether this viewer is CONSUMING film.
334
+ * What this viewer's link was last measured to carry, or null when nothing
335
+ * recent measures it.
336
+ *
337
+ * HOW LONG A READING DESCRIBES A LINK is a fact about the reading, so it is
338
+ * answered here and nowhere else. It used to be a constant in the session
339
+ * manager, consulted in four places — once where a reading is chosen, and
340
+ * three more times over a figure that had already passed that very filter,
341
+ * which made those three conditions unreachable.
342
+ *
343
+ * A viewer who has stopped reporting is NOT thereby gone: only the reading
344
+ * expires. Answering both from one place is what stopped a soundtrack's
345
+ * encoder on 2026-09-05.
346
+ *
347
+ * @param {number} [now]
348
+ * @returns {{ linkMbps: number, bufferedAheadSec: number, positionSeconds: number | null, at: number } | null}
349
+ */
350
+ linkReading(now = Date.now()) {
351
+ const reading = this.netReport;
352
+ if (reading === null || now - reading.at > LINK_READING_FRESH_MS) {
353
+ return null;
354
+ }
355
+ return reading;
356
+ }
357
+
358
+ /**
359
+ * Whether this viewer wants film NOW — either watching it or waiting for it.
360
+ *
361
+ * The one predicate every reading about urgency asks, and it is a union of
362
+ * two states rather than the negation of a pause:
288
363
  *
289
- * Two ways of not consuming, and neither is absence: the picture is stopped,
290
- * or the page is not on screen. Both mean nothing in front of them ever falls
291
- * due, so the work goes to whoever is watching — and both leave them a place
292
- * in the priority map, because they are still there and will want it again.
364
+ * playing consuming, and will run dry when their cushion does;
365
+ * waiting consuming nothing only because we have delivered nothing;
366
+ * neither they stopped the picture themselves.
293
367
  *
368
+ * A viewer who has stopped it keeps their place in the priority map — they
369
+ * are still there and will want the film in front of them again — but nothing
370
+ * of theirs falls due, so the work goes to whoever is watching or waiting.
371
+ *
372
+ * A page that is not on screen is not consuming: its timers are throttled and
373
+ * it asks for nothing, which is indistinguishable from a full cushion.
294
374
  * Picture-in-picture is watching with the tab hidden, and the page folds that
295
- * into `onScreen` before it says it, so it needs no case of its own here.
375
+ * into `onScreen` before saying it, so it needs no case of its own here.
296
376
  *
297
377
  * @returns {boolean}
298
378
  */
299
- consumesFilm() {
300
- return this.playing !== false && this.onScreen !== false;
379
+ wantsFilmNow() {
380
+ return (this.playing === true || this.waiting === true) && this.onScreen !== false;
301
381
  }
302
382
 
303
383
  isPresent() {
@@ -323,7 +403,29 @@ export class Viewer {
323
403
  * since. A viewer whose picture is stopped covers nothing and stays where
324
404
  * they are, which is the same formula with the rate at zero.
325
405
  *
326
- * position(now) = stated + rate * (now - statedAt), rate = playing ? 1 : 0
406
+ * position(now) = stated + rate * min(now - statedAt, held),
407
+ * rate = playing ? 1 : 0
408
+ *
409
+ * **THE SECOND TERM IS BOUNDED BY WHAT THEY HELD**, and the bound is stated
410
+ * by the viewer rather than chosen here: nobody can play past the end of what
411
+ * they have. A viewer who said "I hold thirty seconds" and has said nothing
412
+ * for two minutes has played at most thirty of those seconds; the rest of the
413
+ * silence is a viewer who ran dry, or one we are no longer hearing from, and
414
+ * in both cases they are not where the clock alone would put them.
415
+ *
416
+ * Unbounded, it walks a viewer off the end of a film they never started. On
417
+ * 2026-09-14 the tab was hidden for 145 seconds while the page deliberately
418
+ * held start-up until it was shown, the rate was assumed to be one, and this
419
+ * end placed the viewer at 145.979 s — the exact `-ss` the soundtrack's
420
+ * encoder was given, while the browser was asking for segment #0 and getting
421
+ * 503 for sixty seconds. Their cushion was 0.0 s throughout, so this bound
422
+ * alone would have held them at zero whatever the rate said.
423
+ *
424
+ * The bound binds only where the clock has outrun the cushion — a report lost,
425
+ * or a viewer starving — and there it under-states the position. That is the
426
+ * safe direction and deliberately so: under-stating makes material a viewer
427
+ * has already passed, which costs an encoder; over-stating skips material
428
+ * they are about to need, which costs the viewer the picture.
327
429
  *
328
430
  * Held as a stored number instead, it was a staircase: flat for the ten
329
431
  * seconds between reports and then a jump, so everything derived from it —
@@ -342,9 +444,10 @@ export class Viewer {
342
444
  if (this.position === null) {
343
445
  return null;
344
446
  }
345
- const rate = this.playing ? 1 : 0;
447
+ const rate = this.playing === true ? 1 : 0;
346
448
  const elapsedSec = Math.max(0, (now - this.position.at) / 1000);
347
- return this.position.seconds + rate * elapsedSec;
449
+ const held = Number.isFinite(this.bufferedSeconds) ? Math.max(0, this.bufferedSeconds) : 0;
450
+ return this.position.seconds + rate * Math.min(elapsedSec, held);
348
451
  }
349
452
  }
350
453
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @file Which output a viewer has on screen.
3
+ *
4
+ * Every request that names the picture's session — a seek, a progress poll, a
5
+ * link report, a release — means the stream the viewer is actually watching,
6
+ * and after a quality change that is another session. The browser is never told
7
+ * about the swap: it holds one session id for the whole file, which is what
8
+ * keeps the switch out of the state machine on that side.
9
+ *
10
+ * KEPT PER VIEWER, because one picture is shared by everyone watching it and a
11
+ * quality step is a session of its own. With one answer for the session, a step
12
+ * taken by one viewer moved the other one's stream, and that viewer's next seek
13
+ * was forwarded to a rung they never chose. The session's own field remains the
14
+ * answer for a viewer who cannot name themselves, and is the last rung anybody
15
+ * moved to.
16
+ *
17
+ * It is handed the picture, a name, the live sessions and the registry of
18
+ * viewers, and it HOLDS none of them — but it is not read-only: finding a rung
19
+ * that has been disposed, it puts both remembers back on the picture, or the
20
+ * next question gets the same dead answer. It lives in the VIEWER layer because
21
+ * the question is about a person — which of several equally live outputs is
22
+ * theirs — and it knows nothing of encoders, files or heights.
23
+ */
24
+
25
+ /**
26
+ * @param {object} params
27
+ * @param {object} params.base - The picture's own session.
28
+ * @param {string} [params.consumerId] - Whose screen. Empty asks about the
29
+ * session's own last answer, which is what an unnamed browser gets.
30
+ * @param {{ get: (id: string) => object | undefined }} params.sessions - Every live session, by id.
31
+ * @param {{ of: (session: object, consumerId: string) => { activeVariantId: string | null } }} params.viewers
32
+ * @returns {object} A live session, never a disposed one.
33
+ */
34
+ export function activeOutputFor({ base, consumerId = "", sessions, viewers }) {
35
+ const named = consumerId ? base.viewers?.get(consumerId)?.activeVariantId ?? null : null;
36
+ const activeId = named ?? base.activeVariantId;
37
+ if (!activeId || activeId === base.id) {
38
+ return base;
39
+ }
40
+ const active = sessions.get(activeId);
41
+ if (!active || active.state === "disposed") {
42
+ // The rung is gone, so the viewer is watching the picture again. Said in
43
+ // both places that remember it, or the next question gets the same dead
44
+ // answer.
45
+ if (named) {
46
+ viewers.of(base, consumerId).activeVariantId = null;
47
+ }
48
+ if (base.activeVariantId === activeId) {
49
+ base.activeVariantId = base.id;
50
+ }
51
+ return base;
52
+ }
53
+ return active;
54
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @file A viewer's statement about itself, taken in.
3
+ *
4
+ * The VIEWER layer's own entry point, and the whole of what happens when a page
5
+ * says where it is: the output the reporter has on screen is resolved, the
6
+ * statement reaches the person it is about, and readings that have gone stale
7
+ * stop deciding for the people still here.
8
+ *
9
+ * It lived in the session manager, which is a description of an OUTPUT, and the
10
+ * two questions were tangled there: whether this is a measurement of a LINK,
11
+ * and whether this is a statement by a VIEWER. Required to be both, a page that
12
+ * had measured nothing could say nothing — see `Viewer.report`.
13
+ *
14
+ * Given the sessions and the registry as plain values, so nothing here knows
15
+ * how a session is found, what a variant is, or that there is an encoder.
16
+ */
17
+
18
+ import { activeOutputFor } from "./active-output.js";
19
+
20
+ /**
21
+ * Take one report from one viewer.
22
+ *
23
+ * @param {object} params
24
+ * @param {{ get: (id: string) => object | undefined }} params.sessions - Every live session, by id.
25
+ * @param {{ of: (session: object, consumerId: string) => import("./Viewer.js").Viewer }} params.viewers
26
+ * @param {string} params.sessionId - The session the page addresses, which is
27
+ * always the picture's: the browser is not told which rung it is on.
28
+ * @param {object} params.report - What they said about themselves.
29
+ * @param {number} [params.now]
30
+ * @returns {boolean} False when no such session is live, which is the page
31
+ * reporting into one that has been disposed.
32
+ */
33
+ export function recordViewerReport({ sessions, viewers, sessionId, report, now = Date.now() }) {
34
+ const named = sessions.get(sessionId);
35
+ if (!named || named.state === "disposed") {
36
+ return false;
37
+ }
38
+ const consumerId = typeof report?.consumerId === "string" ? report.consumerId : "";
39
+ // The stream on screen is the reporter's own: with two viewers on two rungs,
40
+ // one report says nothing about the other's encoder.
41
+ const session = activeOutputFor({ base: named, consumerId, sessions, viewers });
42
+ viewers.of(session, consumerId).report(report, now);
43
+ return true;
44
+ }