@torrent-tv/proxy 2.90.0 → 2.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +5 -2
- package/package.json +18 -2
- package/routes/api/playback-plan/post.js +21 -4
- package/routes/api/sources/files/get.js +20 -28
- package/routes/api/sources/post.js +4 -2
- package/routes/api/sources/stats/get.js +4 -11
- package/routes/api/sources/viewer/post.js +16 -0
- package/routes/api/sources/warm/post.js +6 -215
- package/routes/api/subtitles/get.js +22 -125
- package/routes/api/subtitles/post.js +26 -0
- package/routes/api/transcode-sessions/post.js +24 -2
- package/routes/media/get.js +80 -0
- package/routes/stream/get.js +26 -94
- package/routes/transcode/audio-warm/get.js +9 -21
- package/routes/transcode/variant-warm/get.js +8 -14
- package/server.js +410 -46
- package/services/encode/AdmittedInput.js +136 -0
- package/services/encode/CushionReport.js +1 -76
- package/services/encode/EncodeInputs.js +142 -0
- package/services/encode/EncodeOrchestrator.js +4 -1
- package/services/encode/EncodeRun.js +13 -4
- package/services/encode/EncodeRuns.js +132 -52
- package/services/encode/InputFailures.js +37 -0
- package/services/encode/MatroskaInput.js +158 -0
- package/services/encode/OutputOpening.js +16 -64
- package/services/encode/Renditions.js +1 -17
- package/services/encode/admitted-command.js +60 -0
- package/services/encode/annex-b-input.js +37 -0
- package/services/encode/bit-payload.js +29 -0
- package/services/encode/output/OutputCatalog.js +1 -1
- package/services/encode/output/Timeline.js +11 -0
- package/services/encode/pcm-normalization.js +30 -0
- package/services/encode/piece-completeness.js +54 -1
- package/services/encode/quality/HostLoad.js +19 -73
- package/services/encode/quality/QualityOffer.js +1 -1
- package/services/encode/run-command.js +1 -589
- package/services/encode/segment-formats/mp4-boxes.js +3 -1
- package/services/encode/segment-formats/presentation-segment.js +114 -0
- package/services/media/ContainerOrchestrator.js +118 -48
- package/services/media/KeyframeTables.js +16 -115
- package/services/media/MediaReadRequests.js +120 -0
- package/services/media/ProbeRequests.js +135 -0
- package/services/media/SegmentInputs.js +48 -0
- package/services/media/SourceFile.js +6 -0
- package/services/media/SubtitleOrchestrator.js +190 -4
- package/services/media/await-plan.js +47 -0
- package/services/media/container/AsfContainer.js +217 -0
- package/services/media/container/AviContainer.js +343 -34
- package/services/media/container/CadencedPictureOrder.js +45 -0
- package/services/media/container/Container.js +7 -3
- package/services/media/container/ContainerFactory.js +10 -3
- package/services/media/container/FfprobeContainer.js +222 -0
- package/services/media/container/MatroskaContainer.js +72 -46
- package/services/media/container/Mp4Container.js +472 -139
- package/services/media/container/MpegPsContainer.js +281 -0
- package/services/media/container/MpegTsContainer.js +277 -0
- package/services/media/container/PacketIndex.js +270 -0
- package/services/media/container/PacketRecords.js +237 -0
- package/services/media/container/RetainedBytes.js +39 -0
- package/services/media/container/RetainedReads.js +51 -0
- package/services/media/container/ac3-frame.js +46 -0
- package/services/media/container/asf-packets.js +153 -0
- package/services/media/container/avc-picture-order.js +50 -0
- package/services/media/container/avi-open-dml.js +47 -0
- package/services/media/container/dts-frame.js +51 -0
- package/services/media/container/ebml-stream.js +12 -1
- package/services/media/container/ffprobe-record.js +45 -0
- package/services/media/container/flv-packet-ranges.js +27 -0
- package/services/media/container/following-packet-time.js +37 -0
- package/services/media/container/h264-configuration.js +183 -0
- package/services/media/container/h264-elementary-index.js +245 -0
- package/services/media/container/hevc-configuration.js +165 -0
- package/services/media/container/hevc-elementary-index.js +132 -0
- package/services/media/container/hevc-picture-order.js +66 -0
- package/services/media/container/latm-frame.js +99 -0
- package/services/media/container/matroska-packets.js +207 -0
- package/services/media/container/memory-unavailable.js +8 -0
- package/services/media/container/mp3-packet-dependencies.js +41 -0
- package/services/media/container/mp4-fragments.js +148 -0
- package/services/media/container/mpeg-audio-frame.js +19 -0
- package/services/media/container/mpeg-audio-reservoir.js +27 -0
- package/services/media/container/mpeg-elementary-index.js +196 -0
- package/services/media/container/mpeg-sequence-reader.js +69 -0
- package/services/media/container/mpeg-ts-packets.js +93 -0
- package/services/media/container/mpeg-video-configuration.js +50 -0
- package/services/media/container/mpeg4-picture-timing.js +108 -0
- package/services/media/container/pes-header.js +34 -0
- package/services/media/playback-declarations.js +57 -0
- package/services/media/playback-planner.js +72 -230
- package/services/media/probe-packets.js +61 -0
- package/services/media/read-playback-declarations.js +18 -0
- package/services/media/tracks/AudioTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +4 -1
- package/services/server/OutputLifecycle.js +2 -0
- package/services/server/SegmentServing.js +40 -33
- package/services/server/ViewerRequests.js +22 -41
- package/services/server/controllers/PlaybackController.js +4 -0
- package/services/server/controllers/SubtitleController.js +8 -29
- package/services/server/transcode-session-files.js +58 -99
- package/services/server/wire-outputs.js +58 -15
- package/services/storage/IndexMemory.js +85 -0
- package/services/storage/MachineBudget.js +27 -3
- package/services/storage/allowance.js +8 -1
- package/services/storage/piece-store/piece-disk-store.js +18 -3
- package/services/storage/piece-store/piece-lru.js +8 -4
- package/services/storage/piece-store/shared-piece-store.js +26 -11
- package/services/storage/segment-store/SegmentStore.js +49 -18
- package/services/storage/wire.js +7 -0
- package/services/torrent/Contents.js +52 -20
- package/services/torrent/demand/DemandRegister.js +13 -4
- package/services/torrent/demand/Window.js +13 -2
- package/services/torrent/demand-progress.js +25 -0
- package/services/torrent/download/DeadlineScheduler.js +167 -0
- package/services/torrent/download/FutureDownload.js +166 -0
- package/services/torrent/download/SwarmSelection.js +30 -59
- package/services/torrent/download/registry.js +45 -5
- package/services/torrent/download/withdraw-claim.js +2 -0
- package/services/torrent/torrent-pool.js +39 -600
- package/services/torrent/worker/channel.js +8 -1
- package/services/torrent/worker/client.js +74 -72
- package/services/torrent/worker/files-in-use.js +9 -0
- package/services/torrent/worker/handle-state.js +12 -0
- package/services/torrent/worker/held-bytes.js +82 -99
- package/services/torrent/worker/held-file-bytes.js +17 -0
- package/services/torrent/worker/piece-reader.js +67 -683
- package/services/torrent/worker/pool-adapter.js +37 -123
- package/services/torrent/worker/protocol.js +6 -17
- package/services/torrent/worker/whole-file-stats.js +2 -0
- package/services/torrent/worker/worker.js +86 -156
- package/services/transport/data-channel-handler.js +56 -13
- package/services/transport/tunnel-client.js +111 -20
- package/services/viewer/DownloadMaps.js +168 -0
- package/services/viewer/NativeSourceMap.js +33 -0
- package/services/viewer/PriorityMap.js +49 -40
- package/services/viewer/PriorityOrchestrator.js +42 -13
- package/services/viewer/SourcePreparation.js +278 -0
- package/services/viewer/Viewer.js +11 -0
- package/services/viewer/Viewers.js +109 -2
- package/services/viewer/playback-readiness.js +50 -63
- package/services/viewer/segment-demands.js +37 -0
- package/services/viewer/urgent-output-ready.js +26 -0
- package/services/encode/run-inputs.js +0 -53
- package/services/media/keyframe-probe.js +0 -157
- package/services/media/media-info-probe.js +0 -93
- package/services/torrent/worker/background-fill.js +0 -213
- package/services/torrent/worker/resume-warm.js +0 -107
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* append, and an empty picture for six minutes (field 2026-09-05).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { emptyMap, mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
|
|
19
|
+
import { emptyMap, mapForViewer, mergeMaps, runsOf, pauseCoefficient } from "./PriorityMap.js";
|
|
20
20
|
|
|
21
21
|
export class PriorityOrchestrator {
|
|
22
22
|
/** Where the map goes once it is built. @type {(published: object) => void} */
|
|
@@ -66,6 +66,7 @@ export class PriorityOrchestrator {
|
|
|
66
66
|
/** Whether this output is what that person is consuming, as opposed to one
|
|
67
67
|
* they merely hold a record on. @type {(session: object, viewer: object) => boolean} */
|
|
68
68
|
#watchedBy;
|
|
69
|
+
#urgentReadyFor;
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Who watches an output is this component's own registry, handed in as the
|
|
@@ -85,7 +86,7 @@ export class PriorityOrchestrator {
|
|
|
85
86
|
* this layer does not know; absent, every registered viewer counts, and then
|
|
86
87
|
* the per-output map says the same as the per-file one.
|
|
87
88
|
*/
|
|
88
|
-
constructor({ publish, viewers, allowanceFor, watchedBy }) {
|
|
89
|
+
constructor({ publish, viewers, allowanceFor, watchedBy, urgentReadyFor }) {
|
|
89
90
|
if (typeof viewers?.forOutput !== "function") {
|
|
90
91
|
throw new TypeError("PriorityOrchestrator needs the viewer registry");
|
|
91
92
|
}
|
|
@@ -93,6 +94,16 @@ export class PriorityOrchestrator {
|
|
|
93
94
|
this.#viewers = viewers;
|
|
94
95
|
this.#allowanceFor = typeof allowanceFor === "function" ? allowanceFor : () => 0;
|
|
95
96
|
this.#watchedBy = typeof watchedBy === "function" ? watchedBy : () => true;
|
|
97
|
+
this.#urgentReadyFor = typeof urgentReadyFor === "function" ? urgentReadyFor : () => false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Apply the viewer's same pause attenuation to related source preparation. */
|
|
101
|
+
priorityFor(session, viewer, priority, viewerCount, now = Date.now()) {
|
|
102
|
+
const allowanceSeconds = this.#allowanceFor(session);
|
|
103
|
+
const coefficient = pauseCoefficient({ playing: viewer.playing || viewer.waiting, viewerCount,
|
|
104
|
+
pauseSeconds: viewer.pausedAt === null ? 0 : (now - viewer.pausedAt) / 1000, allowanceSeconds,
|
|
105
|
+
urgentReady: viewer.pausedAt !== null && this.#urgentReadyFor(session, viewer, allowanceSeconds, now) });
|
|
106
|
+
return Math.max(1, 1 + Math.floor((priority - 1) * coefficient));
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/**
|
|
@@ -110,17 +121,21 @@ export class PriorityOrchestrator {
|
|
|
110
121
|
* nobody is watching or the film's length is unknown, which says the same as
|
|
111
122
|
* a map with nothing in it.
|
|
112
123
|
*/
|
|
113
|
-
#mapFrom({ durationSeconds, allowanceSeconds, viewers }) {
|
|
124
|
+
#mapFrom({ durationSeconds, allowanceSeconds, viewers, viewerCount }) {
|
|
114
125
|
if (!(durationSeconds > 0) || !(viewers?.length > 0)) {
|
|
115
126
|
return emptyMap(0);
|
|
116
127
|
}
|
|
128
|
+
const count = viewerCount ?? new Set(viewers.map((one, index) => one.id ?? index)).size;
|
|
117
129
|
return mergeMaps(
|
|
118
130
|
viewers.map((viewer) =>
|
|
119
131
|
mapForViewer({
|
|
120
132
|
atSeconds: viewer.atSeconds,
|
|
121
133
|
durationSeconds,
|
|
122
134
|
allowanceSeconds,
|
|
123
|
-
playing: viewer.playing !== false
|
|
135
|
+
playing: viewer.playing !== false,
|
|
136
|
+
pauseSeconds: viewer.pauseSeconds ?? 0,
|
|
137
|
+
urgentReady: viewer.urgentReady === true,
|
|
138
|
+
viewerCount: count
|
|
124
139
|
})
|
|
125
140
|
)
|
|
126
141
|
);
|
|
@@ -139,7 +154,7 @@ export class PriorityOrchestrator {
|
|
|
139
154
|
* @returns {import("./PriorityMap.js").PriorityMap} One number per second of
|
|
140
155
|
* film, merged over everyone watching it.
|
|
141
156
|
*/
|
|
142
|
-
build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers }) {
|
|
157
|
+
build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers, demandKey = null }) {
|
|
143
158
|
const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
|
|
144
159
|
const key = `${sourceKey}:${fileIndex}`;
|
|
145
160
|
this.#maps.set(key, map);
|
|
@@ -148,7 +163,7 @@ export class PriorityOrchestrator {
|
|
|
148
163
|
// make it do that several times a second. Compared as stretches rather than
|
|
149
164
|
// second by second, which is the same comparison over far fewer values.
|
|
150
165
|
const zones = runsOf(map);
|
|
151
|
-
const shape = JSON.stringify(zones);
|
|
166
|
+
const shape = JSON.stringify({ zones, demandKey });
|
|
152
167
|
if (this.#last.get(key)?.shape !== shape) {
|
|
153
168
|
// The length is remembered with the shape, so that a file whose viewers
|
|
154
169
|
// have all gone can still be spoken for: what is published then is the
|
|
@@ -196,7 +211,12 @@ export class PriorityOrchestrator {
|
|
|
196
211
|
const byOutput = new Map();
|
|
197
212
|
for (const sessions of sessionGroups) {
|
|
198
213
|
for (const session of sessions) {
|
|
199
|
-
const
|
|
214
|
+
const sourceKey = session.file?.sourceKey;
|
|
215
|
+
const fileIndex = session.file?.fileIndex;
|
|
216
|
+
if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
|
|
217
|
+
throw new TypeError("A priority map requires its output file's source and file index.");
|
|
218
|
+
}
|
|
219
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
200
220
|
const durationSeconds = Number(session.file?.durationSeconds) || 0;
|
|
201
221
|
// The first band is as wide as an interruption this file has actually
|
|
202
222
|
// shown on this swarm, never a chosen number.
|
|
@@ -204,11 +224,12 @@ export class PriorityOrchestrator {
|
|
|
204
224
|
let held = byFile.get(key);
|
|
205
225
|
if (!held) {
|
|
206
226
|
held = {
|
|
207
|
-
sourceKey
|
|
208
|
-
fileIndex
|
|
227
|
+
sourceKey,
|
|
228
|
+
fileIndex,
|
|
209
229
|
durationSeconds,
|
|
210
230
|
allowanceSeconds,
|
|
211
|
-
viewers: []
|
|
231
|
+
viewers: [],
|
|
232
|
+
demandKey: []
|
|
212
233
|
};
|
|
213
234
|
byFile.set(key, held);
|
|
214
235
|
}
|
|
@@ -219,15 +240,21 @@ export class PriorityOrchestrator {
|
|
|
219
240
|
const address = session.outputKey ?? "";
|
|
220
241
|
let mine = byOutput.get(address);
|
|
221
242
|
if (!mine) {
|
|
222
|
-
mine = { durationSeconds, allowanceSeconds, viewers: [] };
|
|
243
|
+
mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: key };
|
|
223
244
|
byOutput.set(address, mine);
|
|
224
245
|
}
|
|
225
246
|
for (const viewer of this.#viewers.forOutput(session).values()) {
|
|
226
247
|
if (!viewer.isPresent()) {
|
|
227
248
|
continue;
|
|
228
249
|
}
|
|
250
|
+
held.demandKey.push([session.outputKey, viewer.id, viewer.audio?.trackIndex,
|
|
251
|
+
viewer.audio?.transcode, viewer.activeVariantId, viewer.warmingVariantId,
|
|
252
|
+
viewer.warmingAudioId, this.#watchedBy(session, viewer)]);
|
|
229
253
|
const stated = {
|
|
254
|
+
id: viewer.id,
|
|
230
255
|
atSeconds: viewer.positionSeconds(now) ?? 0,
|
|
256
|
+
pauseSeconds: viewer.pausedAt == null ? 0 : Math.max(0, (now - viewer.pausedAt) / 1000),
|
|
257
|
+
urgentReady: viewer.pausedAt != null && this.#urgentReadyFor(session, viewer, allowanceSeconds, now),
|
|
231
258
|
// WATCHING OR WAITING, not merely playing. Three states reach this
|
|
232
259
|
// one question: a viewer whose picture is advancing, a viewer
|
|
233
260
|
// blocked on material we owe them, and a viewer who stopped it
|
|
@@ -236,9 +263,9 @@ export class PriorityOrchestrator {
|
|
|
236
263
|
// on screen has its timers throttled and asks for nothing, which is
|
|
237
264
|
// indistinguishable from a full cushion, so it is not consuming
|
|
238
265
|
// either — told apart on the page, folded into one question here.
|
|
239
|
-
playing: typeof viewer.wantsFilmNow === "function"
|
|
266
|
+
playing: viewer.pausedAt == null && (typeof viewer.wantsFilmNow === "function"
|
|
240
267
|
? viewer.wantsFilmNow()
|
|
241
|
-
: viewer.playing === true || viewer.waiting === true
|
|
268
|
+
: viewer.playing === true || viewer.waiting === true)
|
|
242
269
|
};
|
|
243
270
|
held.viewers.push(stated);
|
|
244
271
|
if (this.#watchedBy(session, viewer)) {
|
|
@@ -280,6 +307,8 @@ export class PriorityOrchestrator {
|
|
|
280
307
|
}
|
|
281
308
|
}
|
|
282
309
|
for (const [address, one] of byOutput) {
|
|
310
|
+
// Competition is per file, even when two viewers use different outputs.
|
|
311
|
+
one.viewerCount = new Set((byFile.get(one.fileKey)?.viewers ?? []).map((viewer, index) => viewer.id ?? index)).size;
|
|
283
312
|
this.#byOutput.set(address, this.#mapFrom(one));
|
|
284
313
|
}
|
|
285
314
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/** Source preparation is derived from viewers before any output exists. */
|
|
2
|
+
export class SourcePreparation {
|
|
3
|
+
#viewers;
|
|
4
|
+
#candidates;
|
|
5
|
+
#related;
|
|
6
|
+
#priorityFor;
|
|
7
|
+
#inspect;
|
|
8
|
+
#withdraw;
|
|
9
|
+
#failed;
|
|
10
|
+
#reprice;
|
|
11
|
+
#inputReady;
|
|
12
|
+
#readyChanged;
|
|
13
|
+
#work = new Map();
|
|
14
|
+
#revision = 0;
|
|
15
|
+
#completedRevision = null;
|
|
16
|
+
#pending = null;
|
|
17
|
+
#candidateAbort = new AbortController();
|
|
18
|
+
|
|
19
|
+
constructor({ viewers, candidatesFor, relatedFilesFor = () => [], priorityFor = (_viewer, priority) => priority,
|
|
20
|
+
inputReady = async () => false, readyChanged = () => {}, inspect, withdraw, reprice = () => {}, failed }) {
|
|
21
|
+
this.#viewers = viewers;
|
|
22
|
+
this.#candidates = candidatesFor;
|
|
23
|
+
this.#related = relatedFilesFor;
|
|
24
|
+
this.#priorityFor = priorityFor;
|
|
25
|
+
this.#inspect = inspect;
|
|
26
|
+
this.#withdraw = withdraw;
|
|
27
|
+
this.#failed = failed;
|
|
28
|
+
this.#reprice = reprice;
|
|
29
|
+
this.#inputReady = inputReady;
|
|
30
|
+
this.#readyChanged = readyChanged;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
refresh() {
|
|
34
|
+
this.#revision++;
|
|
35
|
+
this.#candidateAbort.abort();
|
|
36
|
+
this.#candidateAbort = new AbortController();
|
|
37
|
+
for (const [key, work] of this.#work) {
|
|
38
|
+
const demand = this.demandFor(work);
|
|
39
|
+
if (demand) { this.#reprice(work, demand); continue; }
|
|
40
|
+
this.#work.delete(key);
|
|
41
|
+
this.#withdraw(work);
|
|
42
|
+
}
|
|
43
|
+
if (!this.#pending) {
|
|
44
|
+
let succeeded = false;
|
|
45
|
+
this.#pending = this.#run().then(() => { succeeded = true; }).catch(error => this.#failed?.(error)).finally(() => {
|
|
46
|
+
this.#pending = null;
|
|
47
|
+
if (succeeded && this.#completedRevision !== this.#revision) void this.refresh();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return this.#pending;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Current owners determine priority; old work never restores a withdrawn read. */
|
|
54
|
+
demandFor(work) {
|
|
55
|
+
if (work.selected && work.statement === "packets" &&
|
|
56
|
+
work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId)) return null;
|
|
57
|
+
if (work.role === "subtitle-embedded" && ["packets", "subtitle-cues"].includes(work.statement) &&
|
|
58
|
+
work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId, true)) return null;
|
|
59
|
+
const owners = this.#viewers.forSource(work.sourceKey).filter(viewer => {
|
|
60
|
+
if (work.ownerId !== undefined && viewer.id !== work.ownerId) return false;
|
|
61
|
+
if (work.role === "subtitle-embedded" && (viewer.subtitle?.fileIndex !== work.fileIndex ||
|
|
62
|
+
viewer.subtitle.trackIndex !== work.trackIndex)) return false;
|
|
63
|
+
const selected = viewer.source.selectedFileIndex;
|
|
64
|
+
if (work.ownerFileIndex !== undefined) return selected === work.ownerFileIndex &&
|
|
65
|
+
(work.role !== "source-rest" || viewer.outputs.size === 0);
|
|
66
|
+
if (work.statement !== "packets") return selected === null || selected === work.fileIndex;
|
|
67
|
+
if (selected !== null) return selected === work.fileIndex && viewer.outputs.size === 0 && work.selected;
|
|
68
|
+
return !work.selected && viewer.source.visibleFileIndices.includes(work.fileIndex);
|
|
69
|
+
});
|
|
70
|
+
if (!owners.length) return null;
|
|
71
|
+
const selected = work.ownerFileIndex === undefined && owners.some(viewer => viewer.source.selectedFileIndex !== null);
|
|
72
|
+
const base = work.role === "source-rest" ? 1 : work.role === "next-episode" ? 5 : work.ownerFileIndex !== undefined ? 10 : 100;
|
|
73
|
+
const demands = owners.map(viewer => {
|
|
74
|
+
const chosenSubtitle = ["subtitle", "subtitle-embedded"].includes(work.role) && viewer.subtitle?.fileIndex === work.fileIndex;
|
|
75
|
+
const ownBase = chosenSubtitle ? 100 : base;
|
|
76
|
+
const priority = this.#priorityFor(viewer, ownBase, work);
|
|
77
|
+
return { priority, urgent: (selected || chosenSubtitle) && priority === ownBase };
|
|
78
|
+
});
|
|
79
|
+
const priority = Math.max(...demands.map(demand => demand.priority));
|
|
80
|
+
const urgent = demands.some(demand => demand.urgent);
|
|
81
|
+
return { priority,
|
|
82
|
+
urgent, deadlineAt: urgent ? 0 : Infinity, order: work.order ?? 0 };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
accepts(work) { return this.#work.get(work.key) === work && this.demandFor(work) !== null; }
|
|
86
|
+
|
|
87
|
+
/** A retired torrent cannot retain completed preparation or accept late reads. */
|
|
88
|
+
forget(sourceKey) {
|
|
89
|
+
this.#revision++;
|
|
90
|
+
this.#candidateAbort.abort();
|
|
91
|
+
this.#candidateAbort = new AbortController();
|
|
92
|
+
for (const [key, work] of this.#work) {
|
|
93
|
+
if (work.sourceKey !== sourceKey) continue;
|
|
94
|
+
this.#work.delete(key);
|
|
95
|
+
this.#withdraw(work);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
urgentReadyFor(viewer) {
|
|
100
|
+
if (!this.subtitleReadyFor(viewer)) return false;
|
|
101
|
+
return [...this.#work.values()].some(work => work.selected && work.ownerId === viewer.id &&
|
|
102
|
+
work.urgentReady === true && viewer.source?.sourceKey === work.sourceKey &&
|
|
103
|
+
viewer.source.selectedFileIndex === work.fileIndex && viewer.outputs.size === 0 &&
|
|
104
|
+
work.positions === this.#positionsFor(work.sourceKey, work.fileIndex, viewer.id));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
subtitleReadyFor(viewer) {
|
|
108
|
+
const selection = viewer.subtitle;
|
|
109
|
+
if (!selection) return true;
|
|
110
|
+
return [...this.#work.values()].some(work => work.sourceKey === selection.sourceKey &&
|
|
111
|
+
work.fileIndex === selection.fileIndex && ["result", "terminal"].includes(work.result) &&
|
|
112
|
+
(selection.trackIndex === null ? work.statement === "subtitle-file" && work.ownerFileIndex === viewer.source.selectedFileIndex
|
|
113
|
+
: work.statement === "subtitle-cues" && work.ownerId === viewer.id && work.trackIndex === selection.trackIndex &&
|
|
114
|
+
work.positions === this.#positionsFor(work.sourceKey, work.fileIndex, viewer.id, true)));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async bytesChanged(sourceKey, fileIndex) {
|
|
118
|
+
let changed = false;
|
|
119
|
+
for (const work of this.#work.values()) {
|
|
120
|
+
if (!work.selected || work.sourceKey !== sourceKey || work.fileIndex !== fileIndex || !work.inputRanges) continue;
|
|
121
|
+
const revision = work.readinessRevision = (work.readinessRevision ?? 0) + 1;
|
|
122
|
+
const ready = await this.#inputReady(work);
|
|
123
|
+
if (revision !== work.readinessRevision || !this.accepts(work)) continue;
|
|
124
|
+
if (work.urgentReady !== ready) {
|
|
125
|
+
work.urgentReady = ready;
|
|
126
|
+
changed = true;
|
|
127
|
+
this.#readyChanged(work, ready);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (changed) for (const work of this.#work.values()) {
|
|
131
|
+
const demand = this.demandFor(work);
|
|
132
|
+
if (demand) this.#reprice(work, demand);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ownsFile(sourceKey, fileIndex) {
|
|
137
|
+
return [...this.#work.values()].some(work => work.sourceKey === sourceKey &&
|
|
138
|
+
work.fileIndex === fileIndex && this.demandFor(work));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#positionsFor(sourceKey, fileIndex, ownerId, includeOutputs = false) {
|
|
142
|
+
return JSON.stringify(this.#viewers.forSource(sourceKey)
|
|
143
|
+
.filter(viewer => viewer.source.selectedFileIndex === fileIndex && (includeOutputs || viewer.outputs.size === 0) &&
|
|
144
|
+
(ownerId === undefined || viewer.id === ownerId))
|
|
145
|
+
.map(viewer => [viewer.id, viewer.position?.seconds ?? 0]).sort((left, right) => left[0].localeCompare(right[0])));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
result(work, result) {
|
|
149
|
+
const current = this.#work.get(work.key);
|
|
150
|
+
if (current !== work || !this.demandFor(work)) return;
|
|
151
|
+
current.result = result.kind;
|
|
152
|
+
if (!["needs-ranges", "needs-memory"].includes(result.kind)) void this.refresh();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async #run() {
|
|
156
|
+
let revision;
|
|
157
|
+
do {
|
|
158
|
+
revision = this.#revision;
|
|
159
|
+
for (const sourceKey of this.#viewers.sourceKeys()) {
|
|
160
|
+
let candidates;
|
|
161
|
+
try { candidates = await this.#availableCandidates(sourceKey, this.#candidateAbort.signal); }
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error?.name === "AbortError") break;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
if (revision !== this.#revision) break;
|
|
167
|
+
const people = this.#viewers.forSource(sourceKey);
|
|
168
|
+
const selected = people.map(viewer => viewer.source.selectedFileIndex).filter(index => index !== null);
|
|
169
|
+
const files = [...new Set([...candidates, ...selected])];
|
|
170
|
+
const related = [...new Set(selected)].flatMap(ownerFileIndex =>
|
|
171
|
+
this.#related(sourceKey, ownerFileIndex).map(file => ({ ...file, ownerFileIndex })));
|
|
172
|
+
for (const viewer of people) {
|
|
173
|
+
if (viewer.subtitle?.trackIndex !== null && viewer.subtitle?.trackIndex !== undefined &&
|
|
174
|
+
viewer.subtitle.fileIndex === viewer.source.selectedFileIndex) {
|
|
175
|
+
related.push({ fileIndex: viewer.subtitle.fileIndex, ownerFileIndex: viewer.source.selectedFileIndex,
|
|
176
|
+
ownerId: viewer.id, trackIndex: viewer.subtitle.trackIndex, role: "subtitle-embedded" });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const [key, work] of this.#work) {
|
|
180
|
+
if (work.sourceKey === sourceKey && !files.includes(work.fileIndex) &&
|
|
181
|
+
!related.some(file => file.fileIndex === work.fileIndex && file.ownerFileIndex === work.ownerFileIndex)) {
|
|
182
|
+
this.#work.delete(key);
|
|
183
|
+
this.#withdraw(work);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const metadata = [];
|
|
187
|
+
for (const [order, fileIndex] of files.entries()) {
|
|
188
|
+
for (const statement of ["tracks", "media-info"]) {
|
|
189
|
+
if (revision !== this.#revision) break;
|
|
190
|
+
const work = this.#request({ sourceKey, fileIndex, statement, order });
|
|
191
|
+
if (!this.demandFor(work)) continue;
|
|
192
|
+
metadata.push(work);
|
|
193
|
+
await this.#read(work);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (revision !== this.#revision || metadata.some(work => !["result", "terminal"].includes(work.result))) continue;
|
|
197
|
+
for (const [order, fileIndex] of files.entries()) {
|
|
198
|
+
if (revision !== this.#revision) break;
|
|
199
|
+
const selections = [{ selected: false }, ...people
|
|
200
|
+
.filter(viewer => viewer.source.selectedFileIndex === fileIndex && viewer.outputs.size === 0)
|
|
201
|
+
.map(viewer => ({ selected: true, ownerId: viewer.id }))];
|
|
202
|
+
for (const selection of selections) {
|
|
203
|
+
const work = this.#request({ sourceKey, fileIndex, statement: "packets", ...selection, order });
|
|
204
|
+
if (this.demandFor(work)) await this.#read(work);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const [order, file] of related.entries()) {
|
|
208
|
+
if (revision !== this.#revision) break;
|
|
209
|
+
if (file.role === "subtitle") {
|
|
210
|
+
await this.#read(this.#request({ sourceKey, ...file, statement: "subtitle-file", order: files.length + order }));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const declarations = [];
|
|
214
|
+
for (const statement of ["tracks", "media-info"]) {
|
|
215
|
+
const work = this.#request({ sourceKey, ...file, statement, order: files.length + order });
|
|
216
|
+
declarations.push(work);
|
|
217
|
+
await this.#read(work);
|
|
218
|
+
}
|
|
219
|
+
if (declarations.some(work => work.result !== "result")) continue;
|
|
220
|
+
const packets = this.#request({ sourceKey, ...file, statement: "packets", order: files.length + order });
|
|
221
|
+
await this.#read(packets);
|
|
222
|
+
if (file.role === "subtitle-embedded" && packets.result === "result") {
|
|
223
|
+
await this.#read(this.#request({ sourceKey, ...file, statement: "subtitle-cues", order: files.length + order }));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} while (revision !== this.#revision);
|
|
228
|
+
this.#completedRevision = revision;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#request(address) {
|
|
232
|
+
if (address.selected && address.statement === "packets") {
|
|
233
|
+
address = { ...address, positions: this.#positionsFor(address.sourceKey, address.fileIndex, address.ownerId) };
|
|
234
|
+
}
|
|
235
|
+
if (address.role === "subtitle-embedded" && ["packets", "subtitle-cues"].includes(address.statement)) {
|
|
236
|
+
address = { ...address, positions: this.#positionsFor(address.sourceKey, address.fileIndex, address.ownerId, true) };
|
|
237
|
+
}
|
|
238
|
+
const key = JSON.stringify([address.sourceKey, address.fileIndex, address.statement, address.selected ?? null,
|
|
239
|
+
address.ownerFileIndex ?? null, address.role ?? null, address.positions ?? null, address.ownerId ?? null, address.trackIndex ?? null]);
|
|
240
|
+
let work = this.#work.get(key);
|
|
241
|
+
if (work && work.order !== address.order) {
|
|
242
|
+
work.order = address.order;
|
|
243
|
+
const demand = this.demandFor(work);
|
|
244
|
+
if (demand) this.#reprice(work, demand);
|
|
245
|
+
}
|
|
246
|
+
if (!work) {
|
|
247
|
+
work = { ...address, key, requestId: `prepare:${key}`, result: null };
|
|
248
|
+
if (this.demandFor(work)) this.#work.set(key, work);
|
|
249
|
+
}
|
|
250
|
+
return work;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#availableCandidates(sourceKey, signal) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
let done = false;
|
|
256
|
+
const finish = (error, value) => {
|
|
257
|
+
if (done) return;
|
|
258
|
+
done = true;
|
|
259
|
+
signal.removeEventListener("abort", aborted);
|
|
260
|
+
if (error) reject(error); else resolve(value);
|
|
261
|
+
};
|
|
262
|
+
const aborted = () => finish(new DOMException("Source preparation was cancelled.", "AbortError"));
|
|
263
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
264
|
+
if (signal.aborted) { aborted(); return; }
|
|
265
|
+
Promise.resolve().then(() => {
|
|
266
|
+
if (signal.aborted) throw new DOMException("Source preparation was cancelled.", "AbortError");
|
|
267
|
+
return this.#candidates(sourceKey);
|
|
268
|
+
}).then(value => finish(null, value), error => finish(error));
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async #read(work) {
|
|
273
|
+
if (work.result !== null || !this.demandFor(work)) return;
|
|
274
|
+
const result = await this.#inspect(work);
|
|
275
|
+
if (this.#work.get(work.key) === work && this.demandFor(work)) work.result = result.kind;
|
|
276
|
+
if (work.selected && work.inputRanges) await this.bytesChanged(work.sourceKey, work.fileIndex);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -192,6 +192,8 @@ export class Viewer {
|
|
|
192
192
|
* @type {Set<string>}
|
|
193
193
|
*/
|
|
194
194
|
this.outputs = new Set();
|
|
195
|
+
/** Source selection exists before any prepared output. */
|
|
196
|
+
this.source = null;
|
|
195
197
|
/**
|
|
196
198
|
* Which output answers THIS viewer, and for how long that stands.
|
|
197
199
|
*
|
|
@@ -225,6 +227,7 @@ export class Viewer {
|
|
|
225
227
|
* @type {Set<string>}
|
|
226
228
|
*/
|
|
227
229
|
this.wantsCuesFor = new Set();
|
|
230
|
+
this.subtitle = null;
|
|
228
231
|
// WHETHER THE PICTURE IS MOVING, and separately WHETHER THIS VIEWER IS
|
|
229
232
|
// BLOCKED ON US. Two booleans, because there are three states and one
|
|
230
233
|
// boolean cannot hold them:
|
|
@@ -254,6 +257,8 @@ export class Viewer {
|
|
|
254
257
|
// the instant they arrive, without the position ever being invented.
|
|
255
258
|
this.playing = false;
|
|
256
259
|
this.waiting = true;
|
|
260
|
+
/** When explicit paused state began; waiting for media is not a pause. */
|
|
261
|
+
this.pausedAt = null;
|
|
257
262
|
// Whether the page carrying this viewer is ON SCREEN, and whether the
|
|
258
263
|
// picture has been pulled out of it.
|
|
259
264
|
//
|
|
@@ -312,6 +317,7 @@ export class Viewer {
|
|
|
312
317
|
return;
|
|
313
318
|
}
|
|
314
319
|
this.#place(seconds, now);
|
|
320
|
+
if (this.pausedAt !== null) this.pausedAt = now;
|
|
315
321
|
// The buffer's history belongs to the place they left.
|
|
316
322
|
this.bufferReadings = [];
|
|
317
323
|
// WHAT THEY HELD AT THE PLACE THEY LEFT SAYS NOTHING ABOUT THE PLACE THEY
|
|
@@ -475,6 +481,11 @@ export class Viewer {
|
|
|
475
481
|
// material, which is the direction that costs an encoder rather than a
|
|
476
482
|
// viewer.
|
|
477
483
|
this.waiting = waiting === undefined ? this.playing === false && held === 0 : waiting === true;
|
|
484
|
+
if (!this.playing && !this.waiting) {
|
|
485
|
+
if (this.pausedAt === null) this.pausedAt = now;
|
|
486
|
+
} else {
|
|
487
|
+
this.pausedAt = null;
|
|
488
|
+
}
|
|
478
489
|
this.inPictureInPicture = inPictureInPicture === undefined ? false : Boolean(inPictureInPicture);
|
|
479
490
|
this.onScreen = onScreen === undefined ? true : Boolean(onScreen);
|
|
480
491
|
this.seen(now);
|
|
@@ -50,6 +50,79 @@ export class Viewers {
|
|
|
50
50
|
this.#onChange = typeof onChange === "function" ? onChange : () => {};
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Register presence before source metadata or an output exists. */
|
|
54
|
+
present(consumerId, now = Date.now()) {
|
|
55
|
+
if (typeof consumerId !== "string" || !consumerId) throw new TypeError("A viewer needs a name.");
|
|
56
|
+
const known = this.#byId.get(consumerId);
|
|
57
|
+
const viewer = known ?? new Viewer(consumerId, now);
|
|
58
|
+
viewer.seen(now);
|
|
59
|
+
viewer.gone = false;
|
|
60
|
+
this.#byId.set(consumerId, viewer);
|
|
61
|
+
if (!known) this.#onChange();
|
|
62
|
+
return viewer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Bind the same viewer to their source before metadata preparation. */
|
|
66
|
+
selectsSource(consumerId, sourceKey, now = Date.now()) {
|
|
67
|
+
if (typeof sourceKey !== "string" || !sourceKey) throw new TypeError("A selected source needs a key.");
|
|
68
|
+
const viewer = this.present(consumerId, now);
|
|
69
|
+
if (viewer.source?.sourceKey !== sourceKey) {
|
|
70
|
+
viewer.source = { sourceKey, visibleFileIndices: [], selectedFileIndex: null };
|
|
71
|
+
viewer.subtitle = null;
|
|
72
|
+
this.#onChange();
|
|
73
|
+
}
|
|
74
|
+
return viewer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
selectsFile(consumerId, sourceKey, fileIndex, now = Date.now(), selection = {}) {
|
|
78
|
+
if (!Number.isSafeInteger(fileIndex) || fileIndex < 0) throw new TypeError("A selected file needs its source index.");
|
|
79
|
+
const viewer = this.selectsSource(consumerId, sourceKey, now);
|
|
80
|
+
const changed = viewer.source.selectedFileIndex !== fileIndex;
|
|
81
|
+
if (changed) {
|
|
82
|
+
viewer.source.selectedFileIndex = fileIndex;
|
|
83
|
+
viewer.source.generation = 0;
|
|
84
|
+
viewer.subtitle = null;
|
|
85
|
+
}
|
|
86
|
+
if (changed || Number.isFinite(selection.positionSeconds)) viewer.moveTo(selection.positionSeconds ?? 0, now);
|
|
87
|
+
if (typeof selection.wantsToPlay === "boolean") {
|
|
88
|
+
viewer.playing = false;
|
|
89
|
+
viewer.waiting = selection.wantsToPlay;
|
|
90
|
+
viewer.pausedAt = selection.wantsToPlay ? null : viewer.pausedAt ?? now;
|
|
91
|
+
}
|
|
92
|
+
if (changed || Object.keys(selection).length) this.#onChange();
|
|
93
|
+
return viewer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
visibleFiles(consumerId, sourceKey, indices, now = Date.now()) {
|
|
97
|
+
if (!Array.isArray(indices) || indices.some(index => !Number.isSafeInteger(index) || index < 0)) {
|
|
98
|
+
throw new TypeError("Visible files need source indices.");
|
|
99
|
+
}
|
|
100
|
+
const viewer = this.selectsSource(consumerId, sourceKey, now);
|
|
101
|
+
const visible = [...new Set(indices)];
|
|
102
|
+
if (JSON.stringify(viewer.source.visibleFileIndices) !== JSON.stringify(visible)) {
|
|
103
|
+
viewer.source.visibleFileIndices = visible;
|
|
104
|
+
this.#onChange();
|
|
105
|
+
}
|
|
106
|
+
return viewer;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Accept a source report only while its original selection is still present. */
|
|
110
|
+
reportSource(consumerId, sourceKey, fileIndex, report, now = Date.now()) {
|
|
111
|
+
const viewer = this.get(consumerId);
|
|
112
|
+
if (!viewer || viewer.gone || viewer.outputs.size !== 0 ||
|
|
113
|
+
viewer.source?.sourceKey !== sourceKey || viewer.source.selectedFileIndex !== fileIndex) return false;
|
|
114
|
+
if (report.generation !== undefined) {
|
|
115
|
+
if (!Number.isSafeInteger(report.generation) || report.generation < (viewer.source.generation ?? 0)) return false;
|
|
116
|
+
viewer.source.generation = report.generation;
|
|
117
|
+
}
|
|
118
|
+
if (report.seek === true && Number.isFinite(report.positionSeconds) && report.positionSeconds >= 0) {
|
|
119
|
+
viewer.moveTo(report.positionSeconds, now);
|
|
120
|
+
}
|
|
121
|
+
viewer.report(report, now);
|
|
122
|
+
this.#onChange();
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
53
126
|
/**
|
|
54
127
|
* This viewer, watching this output.
|
|
55
128
|
*
|
|
@@ -106,6 +179,15 @@ export class Viewers {
|
|
|
106
179
|
return this.#byId.get(consumerId) ?? null;
|
|
107
180
|
}
|
|
108
181
|
|
|
182
|
+
sourceKeys() {
|
|
183
|
+
return new Set([...this.#byId.values()].filter(viewer => viewer.isPresent() && viewer.source)
|
|
184
|
+
.map(viewer => viewer.source.sourceKey));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
forSource(sourceKey) {
|
|
188
|
+
return [...this.#byId.values()].filter(viewer => viewer.isPresent() && viewer.source?.sourceKey === sourceKey);
|
|
189
|
+
}
|
|
190
|
+
|
|
109
191
|
/**
|
|
110
192
|
* A viewer of one output, without creating one.
|
|
111
193
|
*
|
|
@@ -244,7 +326,7 @@ export class Viewers {
|
|
|
244
326
|
return false;
|
|
245
327
|
}
|
|
246
328
|
viewer.outputs.delete(output.id);
|
|
247
|
-
if (viewer.outputs.size === 0) {
|
|
329
|
+
if (viewer.outputs.size === 0 && !viewer.source) {
|
|
248
330
|
// Watching nothing at all: this is a statement that they are gone, and
|
|
249
331
|
// not merely that this one output is no longer theirs.
|
|
250
332
|
viewer.markGone();
|
|
@@ -287,7 +369,7 @@ export class Viewers {
|
|
|
287
369
|
for (const [consumerId, viewer] of this.#byId) {
|
|
288
370
|
if (!viewer.outputs.delete(outputId)) continue;
|
|
289
371
|
if (viewer.activeVariantId === outputId) viewer.activeVariantId = null;
|
|
290
|
-
if (viewer.outputs.size === 0) {
|
|
372
|
+
if (viewer.outputs.size === 0 && !viewer.source) {
|
|
291
373
|
viewer.markGone();
|
|
292
374
|
this.#byId.delete(consumerId);
|
|
293
375
|
}
|
|
@@ -334,6 +416,31 @@ export class Viewers {
|
|
|
334
416
|
return true;
|
|
335
417
|
}
|
|
336
418
|
|
|
419
|
+
/** Record the chosen subtitle without creating or restoring a viewer. */
|
|
420
|
+
selectsSubtitle(consumerId, sourceKey, fileIndex, trackIndex = null) {
|
|
421
|
+
const viewer = this.get(consumerId);
|
|
422
|
+
if (!viewer || viewer.gone || viewer.source?.sourceKey !== sourceKey ||
|
|
423
|
+
!Number.isSafeInteger(fileIndex) || fileIndex < 0 ||
|
|
424
|
+
(trackIndex !== null && (!Number.isSafeInteger(trackIndex) || trackIndex < 0))) return false;
|
|
425
|
+
if (trackIndex !== null && viewer.source.selectedFileIndex !== fileIndex) return false;
|
|
426
|
+
const selection = { sourceKey, fileIndex, trackIndex };
|
|
427
|
+
if (JSON.stringify(viewer.subtitle) !== JSON.stringify(selection)) {
|
|
428
|
+
viewer.subtitle = selection;
|
|
429
|
+
this.#onChange();
|
|
430
|
+
}
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
clearsSubtitle(consumerId, sourceKey) {
|
|
435
|
+
const viewer = this.get(consumerId);
|
|
436
|
+
if (!viewer || viewer.gone || viewer.source?.sourceKey !== sourceKey) return false;
|
|
437
|
+
if (viewer.subtitle !== null) {
|
|
438
|
+
viewer.subtitle = null;
|
|
439
|
+
this.#onChange();
|
|
440
|
+
}
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
337
444
|
/**
|
|
338
445
|
* Who has subtitles switched on for this file.
|
|
339
446
|
*
|