@sentinel-nvr/web 0.1.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.
@@ -0,0 +1,1867 @@
1
+ // src/player/rlog.ts
2
+ var brand = "web";
3
+ var client = null;
4
+ function setRlogClient(c, b) {
5
+ client = c;
6
+ if (b) brand = b;
7
+ }
8
+ function rlog(tag, data) {
9
+ if (!client) return;
10
+ let line;
11
+ try {
12
+ line = JSON.stringify({ tag, b: brand, d: data ?? null, ua: navigator.userAgent.slice(0, 80) });
13
+ } catch {
14
+ line = JSON.stringify({ tag, b: brand });
15
+ }
16
+ client.postText("api/clientlog", line);
17
+ }
18
+
19
+ // src/player/webrtc.ts
20
+ var forceRelayGlobal = false;
21
+ function mobileClient() {
22
+ return /iPhone|iPod|Android|Mobile/i.test(navigator.userAgent) || navigator.maxTouchPoints > 1 && Math.min(screen.width || 9999, screen.height || 9999) < 920;
23
+ }
24
+ function rtcOptions() {
25
+ let capabilities;
26
+ try {
27
+ capabilities = RTCRtpReceiver.getCapabilities ? { audio: RTCRtpReceiver.getCapabilities("audio"), video: RTCRtpReceiver.getCapabilities("video") } : void 0;
28
+ } catch {
29
+ }
30
+ return { userAgent: navigator.userAgent, capabilities, screen: { devicePixelRatio: window.devicePixelRatio || 1, width: screen.width || 1920, height: screen.height || 1080 } };
31
+ }
32
+ var WebRtcSession = class {
33
+ ws;
34
+ pc;
35
+ id;
36
+ active = false;
37
+ connectT;
38
+ trackSig = "";
39
+ api;
40
+ opts;
41
+ cb;
42
+ constructor(api, opts, cb) {
43
+ this.api = api;
44
+ this.opts = opts;
45
+ this.cb = cb;
46
+ }
47
+ get connected() {
48
+ const s = this.pc?.iceConnectionState;
49
+ return s === "connected" || s === "completed";
50
+ }
51
+ start() {
52
+ const params = {};
53
+ if (this.opts.mode === "recorded") {
54
+ params.mode = "recorded";
55
+ params.start = String(Math.round(this.opts.startMs || 0));
56
+ }
57
+ if (this.opts.compat ?? mobileClient()) params.compat = "1";
58
+ let ws;
59
+ try {
60
+ ws = new WebSocket(this.api.signalingUrl(this.opts.camId, params));
61
+ } catch {
62
+ this.cb.onFail("ws");
63
+ return;
64
+ }
65
+ this.ws = ws;
66
+ this.active = true;
67
+ this.connectT = window.setTimeout(() => {
68
+ if (this.active && this.ws === ws && !this.connected) {
69
+ rlog("connect-timeout", { kind: this.opts.mode, ice: this.pc?.iceConnectionState });
70
+ if (!forceRelayGlobal) {
71
+ forceRelayGlobal = true;
72
+ rlog("retry-relay-only", { kind: this.opts.mode });
73
+ this.stop();
74
+ this.start();
75
+ return;
76
+ }
77
+ this.fail("timeout");
78
+ }
79
+ }, 12e3);
80
+ ws.onopen = () => {
81
+ try {
82
+ ws.send(JSON.stringify({ type: "options", options: rtcOptions() }));
83
+ } catch {
84
+ }
85
+ };
86
+ ws.onerror = () => {
87
+ if (this.active && this.ws === ws && !this.pc) this.fail("ws-error");
88
+ };
89
+ ws.onclose = () => {
90
+ if (this.active && this.ws === ws && !this.connected) this.fail("ws-closed");
91
+ };
92
+ ws.onmessage = (ev) => {
93
+ let msg;
94
+ try {
95
+ msg = JSON.parse(ev.data);
96
+ } catch {
97
+ return;
98
+ }
99
+ if (msg.type === "createLocalDescription") this.createLocal(ws, msg);
100
+ else if (msg.type === "setRemoteDescription") this.setRemote(ws, msg);
101
+ else if (msg.type === "addIceCandidate") this.pc?.addIceCandidate(msg.candidate).catch(() => {
102
+ });
103
+ else if (msg.type === "sessionStarted") {
104
+ this.id = msg.id;
105
+ this.cb.onSessionId?.(msg.id);
106
+ } else if (msg.type === "error") {
107
+ if (this.active && this.ws === ws) this.fail(msg.reason || "error");
108
+ }
109
+ };
110
+ }
111
+ stop() {
112
+ this.active = false;
113
+ if (this.connectT) {
114
+ clearTimeout(this.connectT);
115
+ this.connectT = void 0;
116
+ }
117
+ try {
118
+ if (this.ws) {
119
+ this.ws.onmessage = null;
120
+ this.ws.onclose = null;
121
+ this.ws.onerror = null;
122
+ this.ws.close();
123
+ }
124
+ } catch {
125
+ }
126
+ try {
127
+ if (this.pc) {
128
+ this.pc.ontrack = null;
129
+ this.pc.oniceconnectionstatechange = null;
130
+ this.pc.onicecandidate = null;
131
+ this.pc.close();
132
+ }
133
+ } catch {
134
+ }
135
+ this.ws = void 0;
136
+ this.pc = void 0;
137
+ this.id = void 0;
138
+ this.trackSig = "";
139
+ }
140
+ fail(reason) {
141
+ if (!this.active) return;
142
+ this.stop();
143
+ this.cb.onFail(reason);
144
+ }
145
+ ensurePc(setup) {
146
+ if (this.pc) return this.pc;
147
+ let cfg = setup && setup.configuration || { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
148
+ if (forceRelayGlobal) cfg = { ...cfg, iceTransportPolicy: "relay" };
149
+ const pc = new RTCPeerConnection(cfg);
150
+ this.pc = pc;
151
+ if (setup?.datachannel) {
152
+ try {
153
+ const dc = pc.createDataChannel(setup.datachannel.label, setup.datachannel.dict);
154
+ dc.binaryType = "arraybuffer";
155
+ } catch {
156
+ }
157
+ }
158
+ if (setup?.audio) {
159
+ try {
160
+ pc.addTransceiver("audio", { direction: setup.audio.direction || "recvonly" });
161
+ } catch {
162
+ }
163
+ }
164
+ if (setup?.video) {
165
+ try {
166
+ pc.addTransceiver("video", { direction: setup.video.direction || "recvonly" });
167
+ } catch {
168
+ }
169
+ }
170
+ pc.ontrack = () => {
171
+ if (!this.active || this.pc !== pc) return;
172
+ const ms = new MediaStream();
173
+ pc.getReceivers().forEach((r) => {
174
+ if (r.track) ms.addTrack(r.track);
175
+ });
176
+ if (!ms.getVideoTracks().length) return;
177
+ const sig = ms.getTracks().map((t) => t.id).sort().join(",");
178
+ if (this.trackSig === sig) return;
179
+ this.trackSig = sig;
180
+ this.cb.onStream(ms);
181
+ };
182
+ pc.oniceconnectionstatechange = () => {
183
+ if (!this.active || this.pc !== pc) return;
184
+ const st = pc.iceConnectionState;
185
+ if (st === "connected" || st === "completed") {
186
+ if (this.connectT) {
187
+ clearTimeout(this.connectT);
188
+ this.connectT = void 0;
189
+ }
190
+ window.setTimeout(() => {
191
+ if (!this.active || this.pc !== pc) return;
192
+ pc.getStats().then((s) => {
193
+ let dec = -1, recv = -1, vBytes = -1, aBytes = -1;
194
+ s.forEach((r) => {
195
+ if (r.type === "inbound-rtp" && (r.kind || r.mediaType) === "video") {
196
+ dec = r.framesDecoded || 0;
197
+ recv = r.framesReceived ?? -1;
198
+ vBytes = r.bytesReceived || 0;
199
+ }
200
+ if (r.type === "inbound-rtp" && (r.kind || r.mediaType) === "audio") aBytes = r.bytesReceived || 0;
201
+ });
202
+ if (dec <= 0) {
203
+ rlog("dec-check-fail", { kind: this.opts.mode, dec, recv, vBytes, aBytes, relayForced: forceRelayGlobal });
204
+ if (!forceRelayGlobal && vBytes <= 0 && aBytes <= 0) {
205
+ forceRelayGlobal = true;
206
+ rlog("retry-relay-only", { kind: this.opts.mode });
207
+ this.stop();
208
+ this.start();
209
+ return;
210
+ }
211
+ this.fail("no-decode");
212
+ } else rlog("dec-ok", { kind: this.opts.mode, dec, relayForced: forceRelayGlobal });
213
+ }).catch(() => {
214
+ });
215
+ }, this.opts.mode === "live" ? 5e3 : 9e3);
216
+ }
217
+ if (st === "failed") {
218
+ rlog("ice-failed", { kind: this.opts.mode });
219
+ this.fail("ice-failed");
220
+ }
221
+ };
222
+ return pc;
223
+ }
224
+ createLocal(ws, msg) {
225
+ const pc = this.ensurePc(msg.setup);
226
+ const mid = this.connected;
227
+ if (mid) rlog("reneg", { dtype: msg.dtype, kind: this.opts.mode });
228
+ const reply = (desc) => {
229
+ try {
230
+ ws.send(JSON.stringify({ type: "response", reqId: msg.reqId, description: { type: desc.type, sdp: desc.sdp } }));
231
+ } catch {
232
+ }
233
+ };
234
+ pc.onicecandidate = (ev) => {
235
+ if (ev.candidate) {
236
+ try {
237
+ ws.send(JSON.stringify({ type: "iceCandidate", candidate: JSON.parse(JSON.stringify(ev.candidate)) }));
238
+ } catch {
239
+ }
240
+ }
241
+ };
242
+ const p = msg.dtype === "offer" ? pc.createOffer({ offerToReceiveAudio: !!msg.setup?.audio, offerToReceiveVideo: !!msg.setup?.video }).then((o) => pc.setLocalDescription(o).then(() => reply(o))) : pc.createAnswer().then((a) => pc.setLocalDescription(a).then(() => reply(a)));
243
+ p.catch((e) => {
244
+ rlog("createLocal-fail", { dtype: msg.dtype, name: e?.name, mid });
245
+ if (!mid) this.fail("createLocal");
246
+ });
247
+ }
248
+ setRemote(ws, msg) {
249
+ const pc = this.ensurePc(msg.setup);
250
+ const mid = this.connected;
251
+ pc.setRemoteDescription(msg.description).then(() => {
252
+ try {
253
+ ws.send(JSON.stringify({ type: "response", reqId: msg.reqId, ok: true }));
254
+ } catch {
255
+ }
256
+ }).catch((e) => {
257
+ rlog("sRD-fail", { name: e?.name, mid });
258
+ try {
259
+ ws.send(JSON.stringify({ type: "response", reqId: msg.reqId, ok: false }));
260
+ } catch {
261
+ }
262
+ if (!mid) this.fail("sRD");
263
+ });
264
+ }
265
+ };
266
+
267
+ // src/player/controller.ts
268
+ var MSCls = window.ManagedMediaSource || window.MediaSource;
269
+ var PlayerController = class {
270
+ v;
271
+ fz;
272
+ img;
273
+ stage;
274
+ api;
275
+ opts;
276
+ camId = "";
277
+ camName = "";
278
+ clips = [];
279
+ codecs = null;
280
+ rangeStart = 0;
281
+ rangeEnd = 0;
282
+ live = false;
283
+ rate = 1;
284
+ soundOn = false;
285
+ recPaused = false;
286
+ recPausedTs = null;
287
+ recWebrtcDisabled = false;
288
+ label = "";
289
+ transport = "none";
290
+ // relay session (RW)
291
+ rw;
292
+ rwStartMs = 0;
293
+ rwBase = null;
294
+ rwPosTs = null;
295
+ rwPosAt = 0;
296
+ rwRate = 1;
297
+ rwSrate = 1;
298
+ rwScrub = false;
299
+ rwSeekBusy = false;
300
+ rwPending = null;
301
+ rwLastSeekTs = null;
302
+ rwLastSeekAt = 0;
303
+ rwRateBusy = false;
304
+ rwPendingRate = null;
305
+ rwLastRateAt = 0;
306
+ relayPoll;
307
+ wdLastCt = -1;
308
+ wdLastAt = 0;
309
+ wdDead = 0;
310
+ wdGrace = 0;
311
+ wdResumeLogged = false;
312
+ recoveries = 0;
313
+ posterUntilSeek = false;
314
+ seekTarget = 0;
315
+ // relay-pos answers are only valid for the command generation they were asked under; a poll sent BEFORE a
316
+ // seek/rate change and answered after it would drag the playhead back to the old position (and the
317
+ // auto-follow timeline with it → visible back-and-forth). seekAt: the server may still report the pre-swap
318
+ // position for a moment after a seek — ignore far-off values in that window.
319
+ cmdSeq = 0;
320
+ seekAt = 0;
321
+ // live session (W) + live MSE (L)
322
+ w;
323
+ L = { active: false, ms: null, sb: null, abort: null, queue: [], restarts: 0, lastTrim: 0, stallT: 0 };
324
+ // recorded MSE (M)
325
+ M = { ms: null, sb: null, base: 0, nextIdx: -1, end: 0, active: false, feeding: false, abort: null, q: [], segFirst: false, segOff: 0, pendingSeek: null, mmsGo: true };
326
+ CH = { active: false, target: 0, timer: 0 };
327
+ curClipId = null;
328
+ playIndex = -1;
329
+ lastLoad = 0;
330
+ pendingTs = null;
331
+ pendingT;
332
+ // frame counter / freeze
333
+ fc = { n: 0, tok: {} };
334
+ freezeT = 0;
335
+ freezeTok = null;
336
+ cad = { t: 0, last: -1, lastNew: 0, stalls: 0, maxStall: 0, frames0: -1, t0: 0, gapMax: 0 };
337
+ stallTimer = 0;
338
+ destroyed = false;
339
+ unlisten = [];
340
+ arPrefix;
341
+ constructor(api, opts) {
342
+ this.api = api;
343
+ this.opts = opts;
344
+ this.arPrefix = opts.storagePrefix ?? "snvr-ar-";
345
+ setRlogClient(api, opts.brand);
346
+ }
347
+ attach(r) {
348
+ this.v = r.video;
349
+ this.fz = r.freeze;
350
+ this.img = r.img;
351
+ this.stage = r.stage;
352
+ const v = this.v;
353
+ const on = (n, f) => {
354
+ v.addEventListener(n, f);
355
+ this.unlisten.push(() => v.removeEventListener(n, f));
356
+ };
357
+ on("timeupdate", () => {
358
+ if (this.live) return;
359
+ if (this.rw?.active && this.rwBase == null && v.currentTime > 0) this.rwBase = v.currentTime;
360
+ const ts = this.currentTs();
361
+ if (ts == null) return;
362
+ if (this.M.active || this.rw?.active) {
363
+ const i = this.clipIndexFor(ts);
364
+ if (i >= 0) this.playIndex = i;
365
+ }
366
+ this.feed();
367
+ this.emit();
368
+ });
369
+ on("waiting", () => {
370
+ if (this.live) {
371
+ if (this.L.active && !this.L.stallT) this.L.stallT = window.setTimeout(() => {
372
+ this.L.stallT = 0;
373
+ if (this.live && this.L.active && v.readyState < 3) this.liveRestart();
374
+ }, 4e3);
375
+ return;
376
+ }
377
+ if (!this.rw?.active) this.setLabel("loading");
378
+ if (!this.M.active || this.stallTimer) return;
379
+ this.stallTimer = window.setTimeout(() => {
380
+ this.stallTimer = 0;
381
+ this.handleStall();
382
+ }, 600);
383
+ });
384
+ on("seeking", () => {
385
+ if (!this.live && !this.rw?.active) this.setLabel("loading");
386
+ });
387
+ for (const n of ["loadeddata", "playing", "canplay", "seeked"]) on(n, () => {
388
+ if (v.videoWidth) this.freezeHide();
389
+ if (!this.live && this.label === "loading") this.setLabel("playing");
390
+ if (this.L.stallT) {
391
+ clearTimeout(this.L.stallT);
392
+ this.L.stallT = 0;
393
+ }
394
+ this.emit();
395
+ });
396
+ on("pause", () => {
397
+ if (this.L.stallT) {
398
+ clearTimeout(this.L.stallT);
399
+ this.L.stallT = 0;
400
+ }
401
+ this.emit();
402
+ });
403
+ on("play", () => this.emit());
404
+ on("loadedmetadata", () => this.setAspect(this.v.videoWidth, this.v.videoHeight));
405
+ on("resize", () => this.setAspect(this.v.videoWidth, this.v.videoHeight));
406
+ on("volumechange", () => this.emit());
407
+ on("error", () => {
408
+ const e = v.error;
409
+ rlog("video-error", { code: e?.code, msg: e?.message?.slice(0, 120), src: v.getAttribute("src") ? "src" : "srcObject" });
410
+ });
411
+ on("ended", () => {
412
+ if (this.live || this.M.active || this.rw?.active) return;
413
+ const n = this.playIndex + 1;
414
+ if (n < this.clips.length) {
415
+ const c = this.clips[n];
416
+ this.curClipId = c.id;
417
+ this.playIndex = n;
418
+ v.src = this.api.url(`api/segment?id=${encodeURIComponent(c.videoId || c.id)}`);
419
+ v.load();
420
+ v.onloadedmetadata = () => {
421
+ v.playbackRate = this.rate;
422
+ try {
423
+ v.currentTime = 0;
424
+ } catch {
425
+ }
426
+ this.safePlay();
427
+ };
428
+ this.setLabel("playing");
429
+ }
430
+ });
431
+ this.pendingT = window.setInterval(() => {
432
+ if (this.pendingTs != null && Date.now() - this.lastLoad >= 200) {
433
+ const t = this.pendingTs;
434
+ this.pendingTs = null;
435
+ this.playAt(t, { scrub: true });
436
+ }
437
+ }, 120);
438
+ const vis = () => this.onVisibility();
439
+ document.addEventListener("visibilitychange", vis);
440
+ this.unlisten.push(() => document.removeEventListener("visibilitychange", vis));
441
+ const edge = window.setInterval(() => {
442
+ if (this.live && this.L.active) this.liveEdge();
443
+ }, 1e3);
444
+ this.unlisten.push(() => clearInterval(edge));
445
+ }
446
+ destroy() {
447
+ this.destroyed = true;
448
+ this.recWebrtcTeardown();
449
+ this.webrtcTeardown();
450
+ this.mseTeardown();
451
+ this.liveTeardown();
452
+ this.chaseAbort();
453
+ if (this.pendingT) clearInterval(this.pendingT);
454
+ for (const u of this.unlisten) u();
455
+ try {
456
+ this.v.pause();
457
+ this.v.srcObject = null;
458
+ this.v.removeAttribute("src");
459
+ } catch {
460
+ }
461
+ }
462
+ setCamera(camId, name) {
463
+ if (camId !== this.camId) {
464
+ this.recWebrtcDisabled = false;
465
+ }
466
+ this.camId = camId;
467
+ this.camName = name;
468
+ let ar = "";
469
+ try {
470
+ ar = localStorage.getItem(this.arPrefix + camId) || "";
471
+ } catch {
472
+ }
473
+ if (ar) this.stage.style.setProperty("--stage-ar", ar);
474
+ else this.stage.style.removeProperty("--stage-ar");
475
+ }
476
+ /** picture aspect → stage box (mobile layout uses it); posters and video both report it */
477
+ setAspect(w, h) {
478
+ if (!w || !h) return;
479
+ const ar = `${w} / ${h}`;
480
+ if (this.stage.style.getPropertyValue("--stage-ar") === ar) return;
481
+ this.stage.style.setProperty("--stage-ar", ar);
482
+ try {
483
+ localStorage.setItem(this.arPrefix + this.camId, ar);
484
+ } catch {
485
+ }
486
+ }
487
+ /** clips of the loaded range (several days, sorted), codec string, and the range the timeline spans */
488
+ setClips(clips, codecs, rangeStart, rangeEnd) {
489
+ this.clips = clips;
490
+ this.codecs = codecs;
491
+ this.rangeStart = rangeStart;
492
+ this.rangeEnd = rangeEnd;
493
+ }
494
+ // ---- state ---------------------------------------------------------------------
495
+ setLabel(l) {
496
+ this.label = l;
497
+ this.emit();
498
+ }
499
+ emit() {
500
+ if (this.destroyed) return;
501
+ this.opts.onState({ live: this.live, label: this.label, playhead: this.live ? null : this.currentTs(), rate: this.rate, sound: this.soundOn, paused: this.recPaused || this.v.paused && !this.live && !this.rw?.active, transport: this.transport, muted: this.v.muted });
502
+ }
503
+ clipIndexFor(ts) {
504
+ for (let i = 0; i < this.clips.length; i++) {
505
+ const c = this.clips[i];
506
+ if (ts >= c.startTime && ts < c.startTime + (c.duration || 6e4)) return i;
507
+ }
508
+ return -1;
509
+ }
510
+ nearNow(ts) {
511
+ return Date.now() - ts < 8e3;
512
+ }
513
+ clampRange(ts) {
514
+ return Math.min(Math.max(ts, this.rangeStart), Math.min(this.rangeEnd - 1, Date.now()));
515
+ }
516
+ /** Wall-clock position of the picture on screen (Scrypted's getRecordingStreamCurrentTime). */
517
+ currentTs() {
518
+ if (this.rw?.active) {
519
+ if (this.rwPosTs != null) return this.rwPosTs + (Date.now() - this.rwPosAt) * (this.rwRate || 1);
520
+ if (this.rwBase != null && this.v.currentTime > 0) return this.rwStartMs + (this.v.currentTime - this.rwBase) * 1e3 * (this.rwRate || 1);
521
+ return this.rwStartMs;
522
+ }
523
+ if (this.recPaused) return this.recPausedTs;
524
+ if (this.live) return null;
525
+ if (this.M.active) return this.M.base + this.v.currentTime * 1e3;
526
+ const pc = this.playIndex >= 0 ? this.clips[this.playIndex] : void 0;
527
+ if (pc) return pc.startTime + this.v.currentTime * 1e3;
528
+ return null;
529
+ }
530
+ // ---- presented frames / freeze / poster -----------------------------------------
531
+ fcArm() {
532
+ const v = this.v;
533
+ if (!v.requestVideoFrameCallback) return;
534
+ const tok = {};
535
+ this.fc.tok = tok;
536
+ const tick = () => {
537
+ if (this.fc.tok !== tok) return;
538
+ this.fc.n++;
539
+ try {
540
+ v.requestVideoFrameCallback(tick);
541
+ } catch {
542
+ }
543
+ };
544
+ try {
545
+ v.requestVideoFrameCallback(tick);
546
+ } catch {
547
+ }
548
+ }
549
+ presentedFrames() {
550
+ if (this.v.requestVideoFrameCallback) return this.fc.n;
551
+ try {
552
+ return this.v.getVideoPlaybackQuality().totalVideoFrames;
553
+ } catch {
554
+ return -1;
555
+ }
556
+ }
557
+ freezeArm(hold) {
558
+ if (this.freezeT) clearTimeout(this.freezeT);
559
+ this.freezeT = window.setTimeout(() => this.freezeHide(), hold ? 9e4 : 5e3);
560
+ const v = this.v;
561
+ if (hold && v.requestVideoFrameCallback) {
562
+ const tok = {};
563
+ this.freezeTok = tok;
564
+ try {
565
+ v.requestVideoFrameCallback(() => {
566
+ if (this.freezeTok === tok && this.v.videoWidth) this.freezeHide();
567
+ });
568
+ } catch {
569
+ }
570
+ }
571
+ }
572
+ freezeShow(hold = false) {
573
+ try {
574
+ const v = this.v, fc = this.fz;
575
+ if (!v.videoWidth || !v.videoHeight || v.readyState < 2) return;
576
+ if (fc.width !== v.videoWidth) fc.width = v.videoWidth;
577
+ if (fc.height !== v.videoHeight) fc.height = v.videoHeight;
578
+ fc.getContext("2d").drawImage(v, 0, 0, fc.width, fc.height);
579
+ fc.classList.remove("hidden");
580
+ fc.dataset.src = "video";
581
+ this.freezeArm(hold);
582
+ } catch {
583
+ }
584
+ }
585
+ freezeFromImage(img, hold, untilSeek = false, kind = "image") {
586
+ try {
587
+ if (!img.naturalWidth || !img.naturalHeight) return false;
588
+ const fc = this.fz;
589
+ fc.width = img.naturalWidth;
590
+ fc.height = img.naturalHeight;
591
+ fc.getContext("2d").drawImage(img, 0, 0);
592
+ fc.classList.remove("hidden");
593
+ fc.dataset.src = kind;
594
+ this.setAspect(img.naturalWidth, img.naturalHeight);
595
+ if (untilSeek) {
596
+ if (this.freezeT) clearTimeout(this.freezeT);
597
+ this.freezeTok = null;
598
+ this.posterUntilSeek = true;
599
+ this.freezeT = window.setTimeout(() => this.freezeHide(), 6e3);
600
+ } else this.freezeArm(hold);
601
+ return true;
602
+ } catch {
603
+ return false;
604
+ }
605
+ }
606
+ freezeHide() {
607
+ if (this.freezeT) {
608
+ clearTimeout(this.freezeT);
609
+ this.freezeT = 0;
610
+ }
611
+ this.posterUntilSeek = false;
612
+ this.fz.classList.add("hidden");
613
+ delete this.fz.dataset.src;
614
+ }
615
+ posterUp() {
616
+ return !this.fz.classList.contains("hidden");
617
+ }
618
+ freezeHold() {
619
+ if (this.freezeT) {
620
+ clearTimeout(this.freezeT);
621
+ this.freezeT = window.setTimeout(() => this.freezeHide(), 5e3);
622
+ }
623
+ }
624
+ /** Event click: the stored frame is the poster until the seek lands. */
625
+ posterEvent(ts) {
626
+ const cam = this.camId;
627
+ const img = new Image();
628
+ img.crossOrigin = "anonymous";
629
+ img.onload = () => {
630
+ if (this.camId !== cam) return;
631
+ this.freezeFromImage(img, true, !!this.rw?.active && !!this.rw.id, "event");
632
+ };
633
+ img.src = this.api.url(`api/evframe?camera=${encodeURIComponent(cam)}&ts=${ts}`);
634
+ }
635
+ /** Camera opened: newest snapshot in front of the black stage until live plays. */
636
+ posterFromSnapshot() {
637
+ const cam = this.camId;
638
+ const img = new Image();
639
+ img.crossOrigin = "anonymous";
640
+ img.onload = () => {
641
+ if (this.camId !== cam || this.rw?.active || this.recPaused || this.posterUp()) return;
642
+ if (this.v.readyState >= 2 && this.v.videoWidth && !this.v.paused) return;
643
+ this.freezeFromImage(img, true, false, "snapshot");
644
+ };
645
+ img.src = this.api.url(`api/snapshot?camera=${encodeURIComponent(cam)}`) + `&_=${Date.now()}`;
646
+ }
647
+ posterShow(ts) {
648
+ try {
649
+ if (this.posterUp()) return;
650
+ if (this.v.readyState >= 2 && this.v.videoWidth) return;
651
+ const i = this.clipIndexFor(ts);
652
+ const c = i >= 0 ? this.clips[i] : void 0;
653
+ if (!c?.thumbnailId) return;
654
+ const img = new Image();
655
+ img.crossOrigin = "anonymous";
656
+ img.onload = () => {
657
+ if (!this.posterUp()) this.freezeFromImage(img, true, false, "thumb");
658
+ };
659
+ img.src = this.api.url(`api/thumb?id=${encodeURIComponent(c.thumbnailId)}`);
660
+ } catch {
661
+ }
662
+ }
663
+ safePlay() {
664
+ let pr;
665
+ try {
666
+ pr = this.v.play();
667
+ } catch {
668
+ return;
669
+ }
670
+ pr.then(() => this.emit()).catch((e1) => {
671
+ this.v.muted = true;
672
+ this.v.play().then(() => this.emit()).catch((e2) => rlog("play-fail", { e1: e1?.name, e2: e2?.name, rs: this.v.readyState, paused: this.v.paused }));
673
+ });
674
+ }
675
+ // ---- LIVE ---------------------------------------------------------------------------
676
+ goLive() {
677
+ if (!this.camId) return;
678
+ this.chaseAbort();
679
+ this.freezeShow(true);
680
+ this.exitLiveState();
681
+ this.recWebrtcTeardown();
682
+ this.recPaused = false;
683
+ this.live = true;
684
+ this.playIndex = -1;
685
+ this.curClipId = null;
686
+ this.L.restarts = 0;
687
+ this.setLabel("live");
688
+ if (document.visibilityState === "hidden") return;
689
+ if (window.RTCPeerConnection && window.WebSocket) this.liveStartWebrtc();
690
+ else if (this.liveMseOk()) this.liveStartMse();
691
+ else {
692
+ try {
693
+ this.v.pause();
694
+ } catch {
695
+ }
696
+ this.liveFallbackImg();
697
+ }
698
+ }
699
+ exitLiveState() {
700
+ if (this.live) {
701
+ this.live = false;
702
+ this.webrtcTeardown();
703
+ this.liveTeardown();
704
+ try {
705
+ this.v.srcObject = null;
706
+ } catch {
707
+ }
708
+ this.setMjpeg(false);
709
+ }
710
+ }
711
+ setMjpeg(on) {
712
+ this.img.classList.toggle("hidden", !on);
713
+ this.v.classList.toggle("hidden", on);
714
+ if (!on) this.img.removeAttribute("src");
715
+ }
716
+ webrtcTeardown() {
717
+ this.w?.stop();
718
+ this.w = void 0;
719
+ }
720
+ liveStartWebrtc() {
721
+ this.webrtcTeardown();
722
+ this.recWebrtcTeardown();
723
+ this.mseTeardown();
724
+ this.liveTeardown();
725
+ try {
726
+ this.v.pause();
727
+ } catch {
728
+ }
729
+ try {
730
+ this.v.srcObject = null;
731
+ } catch {
732
+ }
733
+ this.v.removeAttribute("src");
734
+ this.setMjpeg(false);
735
+ const s = new WebRtcSession(this.api, { camId: this.camId, mode: "live" }, {
736
+ onStream: (ms) => {
737
+ if (!this.live || this.w !== s) return;
738
+ this.setMjpeg(false);
739
+ this.v.muted = !this.soundOn;
740
+ try {
741
+ this.v.removeAttribute("src");
742
+ } catch {
743
+ }
744
+ try {
745
+ this.v.srcObject = ms;
746
+ } catch {
747
+ }
748
+ this.fcArm();
749
+ this.safePlay();
750
+ this.transport = "webrtc";
751
+ this.setLabel("liveWebrtc");
752
+ },
753
+ onFail: () => {
754
+ if (this.w === s) this.liveFallbackMse();
755
+ }
756
+ });
757
+ this.w = s;
758
+ this.transport = "webrtc";
759
+ s.start();
760
+ }
761
+ liveFallbackMse() {
762
+ this.webrtcTeardown();
763
+ if (!this.live) return;
764
+ try {
765
+ this.v.srcObject = null;
766
+ } catch {
767
+ }
768
+ if (this.liveMseOk()) this.liveStartMse();
769
+ else this.liveFallbackImg();
770
+ }
771
+ liveCodec() {
772
+ return this.codecs ? this.codecs.split(",")[0] ?? null : null;
773
+ }
774
+ liveMseOk() {
775
+ try {
776
+ const c = this.liveCodec();
777
+ return !!(this.api.corsMedia && MSCls && c && MSCls.isTypeSupported(`video/mp4; codecs="${c}"`));
778
+ } catch {
779
+ return false;
780
+ }
781
+ }
782
+ liveTeardown() {
783
+ const L = this.L;
784
+ L.active = false;
785
+ L.queue = [];
786
+ if (L.stallT) {
787
+ clearTimeout(L.stallT);
788
+ L.stallT = 0;
789
+ }
790
+ try {
791
+ L.abort?.abort();
792
+ } catch {
793
+ }
794
+ L.abort = null;
795
+ if (L.sb && L.ms && L.ms.readyState === "open") {
796
+ try {
797
+ L.sb.abort();
798
+ } catch {
799
+ }
800
+ }
801
+ L.sb = null;
802
+ L.ms = null;
803
+ }
804
+ liveAppend() {
805
+ const L = this.L;
806
+ if (!L.active || !L.sb || L.sb.updating || !L.queue.length) return;
807
+ const buf = L.queue.shift();
808
+ try {
809
+ L.sb.appendBuffer(buf);
810
+ } catch {
811
+ this.liveRestart();
812
+ }
813
+ }
814
+ liveTrim() {
815
+ const L = this.L;
816
+ if (!L.sb || L.sb.updating) return;
817
+ try {
818
+ const b = this.v.buffered;
819
+ if (b.length && this.v.currentTime - b.start(0) > 30) L.sb.remove(0, this.v.currentTime - 10);
820
+ } catch {
821
+ }
822
+ }
823
+ liveEdge() {
824
+ try {
825
+ const b = this.v.buffered;
826
+ if (!b.length) return;
827
+ const end = b.end(b.length - 1);
828
+ if (end - this.v.currentTime > 2.5) this.v.currentTime = end - 0.7;
829
+ if (this.v.paused) this.safePlay();
830
+ } catch {
831
+ }
832
+ }
833
+ liveRestart() {
834
+ if (!this.live || !this.L.active) return;
835
+ this.liveTeardown();
836
+ this.L.restarts++;
837
+ if (this.L.restarts > 5) {
838
+ this.liveFallbackImg();
839
+ return;
840
+ }
841
+ window.setTimeout(() => {
842
+ if (this.live) this.liveStartMse();
843
+ }, 1e3);
844
+ }
845
+ liveStartMse() {
846
+ this.liveTeardown();
847
+ this.mseTeardown();
848
+ const L = this.L;
849
+ L.active = true;
850
+ this.transport = "mse";
851
+ this.setLabel("liveMse");
852
+ this.setMjpeg(false);
853
+ const ms = new MSCls();
854
+ L.ms = ms;
855
+ L.lastTrim = Date.now();
856
+ if (window.ManagedMediaSource && ms instanceof window.ManagedMediaSource) {
857
+ try {
858
+ this.v.disableRemotePlayback = true;
859
+ } catch {
860
+ }
861
+ }
862
+ const u = URL.createObjectURL(ms);
863
+ ms.addEventListener("sourceopen", () => {
864
+ URL.revokeObjectURL(u);
865
+ if (L.ms !== ms) return;
866
+ let sb;
867
+ try {
868
+ sb = ms.addSourceBuffer(`video/mp4; codecs="${this.liveCodec()}"`);
869
+ } catch {
870
+ this.liveFallbackImg();
871
+ return;
872
+ }
873
+ sb.mode = "segments";
874
+ sb.addEventListener("updateend", () => {
875
+ if (Date.now() - L.lastTrim > 1e4) {
876
+ L.lastTrim = Date.now();
877
+ this.liveTrim();
878
+ }
879
+ this.liveAppend();
880
+ });
881
+ sb.addEventListener("error", () => this.liveRestart());
882
+ L.sb = sb;
883
+ L.abort = new AbortController();
884
+ fetch(this.api.url(`api/livemse?camera=${encodeURIComponent(this.camId)}`), { credentials: "same-origin", signal: L.abort.signal }).then((r) => {
885
+ if (!r.ok || !r.body) throw new Error(String(r.status));
886
+ const rd = r.body.getReader();
887
+ const loop = () => {
888
+ rd.read().then((x) => {
889
+ if (!L.active || L.ms !== ms) return;
890
+ if (x.done) {
891
+ this.liveRestart();
892
+ return;
893
+ }
894
+ L.queue.push(x.value);
895
+ this.liveAppend();
896
+ loop();
897
+ }).catch(() => {
898
+ if (L.active && L.ms === ms) this.liveRestart();
899
+ });
900
+ };
901
+ loop();
902
+ }).catch(() => {
903
+ if (L.active && L.ms === ms) this.liveFallbackImg();
904
+ });
905
+ });
906
+ try {
907
+ this.v.srcObject = null;
908
+ } catch {
909
+ }
910
+ this.v.src = u;
911
+ this.v.playbackRate = 1;
912
+ this.v.muted = true;
913
+ this.safePlay();
914
+ }
915
+ liveFallbackImg() {
916
+ this.liveTeardown();
917
+ if (!this.live) return;
918
+ try {
919
+ this.v.pause();
920
+ } catch {
921
+ }
922
+ this.freezeHide();
923
+ this.transport = "mjpeg";
924
+ this.setLabel("liveMjpeg");
925
+ this.setMjpeg(true);
926
+ this.img.src = this.api.url(`api/live?camera=${encodeURIComponent(this.camId)}`) + `&_=${Date.now()}`;
927
+ }
928
+ // ---- RECORDED via relay -----------------------------------------------------------------
929
+ recWebrtcOk() {
930
+ return !this.recWebrtcDisabled && !!(window.RTCPeerConnection && window.WebSocket);
931
+ }
932
+ recWebrtcTeardown() {
933
+ this.posterUntilSeek = false;
934
+ this.stopRelayPoll();
935
+ this.rw?.stop();
936
+ this.rw = void 0;
937
+ this.rwBase = null;
938
+ this.rwPosTs = null;
939
+ this.rwScrub = false;
940
+ this.rwSeekBusy = false;
941
+ this.rwPending = null;
942
+ this.rwLastSeekTs = null;
943
+ this.rwSrate = 1;
944
+ this.rwRate = 1;
945
+ this.rwRateBusy = false;
946
+ this.rwPendingRate = null;
947
+ }
948
+ recWebrtcStart(ts) {
949
+ this.freezeShow(true);
950
+ this.posterShow(ts);
951
+ this.live = false;
952
+ this.recWebrtcTeardown();
953
+ this.webrtcTeardown();
954
+ this.mseTeardown();
955
+ this.liveTeardown();
956
+ try {
957
+ this.v.pause();
958
+ } catch {
959
+ }
960
+ try {
961
+ this.v.removeAttribute("src");
962
+ this.v.srcObject = null;
963
+ } catch {
964
+ }
965
+ this.setMjpeg(false);
966
+ this.transport = "relay";
967
+ this.setLabel("loading");
968
+ this.rwStartMs = ts;
969
+ this.rwBase = null;
970
+ this.rwRate = this.rate;
971
+ this.rwSrate = 1;
972
+ this.seekTarget = ts;
973
+ const compat = mobileClient();
974
+ if (compat) rlog("rec-compat-start", { ts: Math.round(ts) });
975
+ const s = new WebRtcSession(this.api, { camId: this.camId, mode: "recorded", startMs: ts, compat }, {
976
+ onStream: (ms) => {
977
+ if (this.rw !== s || this.live) return;
978
+ try {
979
+ s.pc?.getReceivers().forEach((r) => {
980
+ try {
981
+ r.jitterBufferTarget = 500;
982
+ } catch {
983
+ }
984
+ try {
985
+ r.playoutDelayHint = 0.5;
986
+ } catch {
987
+ }
988
+ });
989
+ } catch {
990
+ }
991
+ this.setMjpeg(false);
992
+ this.v.muted = !this.soundOn || this.rate !== 1;
993
+ try {
994
+ this.v.removeAttribute("src");
995
+ } catch {
996
+ }
997
+ try {
998
+ this.v.srcObject = ms;
999
+ } catch {
1000
+ }
1001
+ this.fcArm();
1002
+ this.rwBase = null;
1003
+ this.safePlay();
1004
+ this.setLabel("playing");
1005
+ },
1006
+ onFail: () => {
1007
+ if (this.rw === s) this.recFallbackMse(ts);
1008
+ },
1009
+ onSessionId: () => {
1010
+ if (this.rw === s && this.rwPending) {
1011
+ const p = this.rwPending;
1012
+ this.rwPending = null;
1013
+ if (Math.abs(p.ts - this.rwStartMs) > 1500) this.recRelaySeek(p.ts, p.rate, p.srate);
1014
+ }
1015
+ }
1016
+ });
1017
+ this.rw = s;
1018
+ s.start();
1019
+ this.startRelayPoll();
1020
+ this.emit();
1021
+ }
1022
+ recFallbackMse(ts) {
1023
+ rlog("rec-fallback-mse", { mseOk: this.mseSupported() });
1024
+ this.recWebrtcDisabled = true;
1025
+ this.recWebrtcTeardown();
1026
+ if (this.live || this.destroyed) return;
1027
+ this.playAt(ts, {});
1028
+ }
1029
+ /** Hard server-side seek, coalesced: one in flight, latest wins, 150 ms floor. */
1030
+ recRelaySeek(ts, rate = 1, srate = 1) {
1031
+ const s = this.rw;
1032
+ if (!s?.id) return;
1033
+ if (this.rwSeekBusy) {
1034
+ this.rwPending = { ts, rate, srate };
1035
+ return;
1036
+ }
1037
+ if (this.rwLastSeekTs === ts && this.rwRate === rate && this.rwSrate === srate && Date.now() - this.rwLastSeekAt < 1e3) return;
1038
+ const wait = 150 - (Date.now() - this.rwLastSeekAt);
1039
+ if (wait > 0) {
1040
+ this.rwSeekBusy = true;
1041
+ this.rwPending = { ts, rate, srate };
1042
+ window.setTimeout(() => {
1043
+ this.rwSeekBusy = false;
1044
+ const p = this.rwPending;
1045
+ this.rwPending = null;
1046
+ if (p && this.rw?.id) this.recRelaySeek(p.ts, p.rate, p.srate);
1047
+ }, wait);
1048
+ return;
1049
+ }
1050
+ this.rwStartMs = ts;
1051
+ this.rwSrate = srate;
1052
+ this.rwRate = this.rwScrub ? srate : rate;
1053
+ this.rwPendingRate = null;
1054
+ this.rwLastSeekTs = ts;
1055
+ this.rwLastSeekAt = Date.now();
1056
+ this.cad.lastNew = Date.now();
1057
+ this.wdLastCt = -1;
1058
+ this.wdLastAt = Date.now();
1059
+ this.wdGrace = Date.now() + 5e3;
1060
+ this.rwPosTs = ts;
1061
+ this.rwPosAt = Date.now();
1062
+ this.setLabel("loading");
1063
+ this.rwSeekBusy = true;
1064
+ this.seekTarget = ts;
1065
+ this.seekAt = Date.now();
1066
+ this.cmdSeq++;
1067
+ const done = (ok) => {
1068
+ this.rwSeekBusy = false;
1069
+ if (!ok) {
1070
+ this.relayRecover();
1071
+ return;
1072
+ }
1073
+ const p = this.rwPending;
1074
+ this.rwPending = null;
1075
+ if (p && this.rw?.id) this.recRelaySeek(p.ts, p.rate, p.srate);
1076
+ };
1077
+ this.api.control(`api/relay-seek?session=${encodeURIComponent(s.id)}&start=${Math.round(ts)}&rate=${rate}&srate=${srate}`).then((ok) => done(ok));
1078
+ }
1079
+ /** Scrub time-lapse rate, coalesced (120 ms floor, 15 % hysteresis). */
1080
+ recRelayRate(r) {
1081
+ const s = this.rw;
1082
+ if (!s?.active || !s.id || !this.rwScrub) return;
1083
+ if (this.rwRateBusy) {
1084
+ this.rwPendingRate = r;
1085
+ return;
1086
+ }
1087
+ if (this.rwSrate === r) return;
1088
+ if (this.rwSrate !== 1 && r > 0 === this.rwSrate > 0 && Math.abs(r - this.rwSrate) < 0.15 * Math.abs(this.rwSrate)) return;
1089
+ const wait = 120 - (Date.now() - this.rwLastRateAt);
1090
+ if (wait > 0) {
1091
+ this.rwRateBusy = true;
1092
+ this.rwPendingRate = r;
1093
+ window.setTimeout(() => {
1094
+ this.rwRateBusy = false;
1095
+ const p = this.rwPendingRate;
1096
+ this.rwPendingRate = null;
1097
+ if (p != null && this.rw?.id) this.recRelayRate(p);
1098
+ }, wait);
1099
+ return;
1100
+ }
1101
+ const c = this.currentTs();
1102
+ this.rwSrate = r;
1103
+ this.rwRate = r;
1104
+ this.rwLastRateAt = Date.now();
1105
+ this.cmdSeq++;
1106
+ if (c != null) {
1107
+ this.rwPosTs = c;
1108
+ this.rwPosAt = Date.now();
1109
+ }
1110
+ this.wdGrace = Date.now() + 8e3;
1111
+ this.cad.lastNew = Date.now();
1112
+ this.rwRateBusy = true;
1113
+ this.api.control(`api/relay-rate?session=${encodeURIComponent(s.id)}&rate=${r}`).then((ok) => {
1114
+ if (!ok) this.relayRecover();
1115
+ }).then(() => {
1116
+ this.rwRateBusy = false;
1117
+ const p = this.rwPendingRate;
1118
+ this.rwPendingRate = null;
1119
+ if (p != null && this.rw?.id && p !== this.rwSrate) this.recRelayRate(p);
1120
+ });
1121
+ }
1122
+ recRelayScrub(on) {
1123
+ const s = this.rw;
1124
+ if (!s?.active || !s.id || this.rwScrub === on) return;
1125
+ this.rwScrub = on;
1126
+ this.cmdSeq++;
1127
+ if (!on) {
1128
+ const c = this.currentTs();
1129
+ this.rwSrate = 1;
1130
+ this.rwRate = this.rate;
1131
+ this.rwPendingRate = null;
1132
+ if (c != null) {
1133
+ this.rwPosTs = c;
1134
+ this.rwPosAt = Date.now();
1135
+ }
1136
+ }
1137
+ this.wdLastCt = -1;
1138
+ this.wdLastAt = Date.now();
1139
+ this.wdGrace = Date.now() + 5e3;
1140
+ this.cad.lastNew = Date.now();
1141
+ this.api.control(`api/relay-scrub?session=${encodeURIComponent(s.id)}&on=${on ? 1 : 0}`).then((ok) => {
1142
+ if (!ok) this.relayRecover();
1143
+ });
1144
+ }
1145
+ relayRecover() {
1146
+ if (!this.rw?.active) return;
1147
+ const t = this.rwPosTs ?? this.rwStartMs;
1148
+ this.recoveries++;
1149
+ rlog("relay-recover", { n: this.recoveries });
1150
+ if (this.recoveries > 2) {
1151
+ this.recFallbackMse(t);
1152
+ return;
1153
+ }
1154
+ this.recWebrtcStart(t);
1155
+ }
1156
+ startRelayPoll() {
1157
+ if (this.relayPoll) return;
1158
+ this.cadStart();
1159
+ this.wdLastCt = -1;
1160
+ this.wdLastAt = Date.now();
1161
+ this.wdDead = 0;
1162
+ this.wdGrace = Date.now() + 8e3;
1163
+ this.relayPoll = window.setInterval(() => {
1164
+ const s = this.rw;
1165
+ const v = this.v;
1166
+ if (!s?.active || !s.id) return;
1167
+ const q = this.presentedFrames();
1168
+ if (v.paused && !this.recPaused && !document.hidden) {
1169
+ if (!this.wdResumeLogged) {
1170
+ this.wdResumeLogged = true;
1171
+ rlog("auto-resume", { rs: v.readyState });
1172
+ }
1173
+ this.safePlay();
1174
+ } else if (!v.paused) this.wdResumeLogged = false;
1175
+ if (!v.paused && !document.hidden && this.rwBase != null) {
1176
+ if (q !== this.wdLastCt) {
1177
+ this.wdLastCt = q;
1178
+ this.wdLastAt = Date.now();
1179
+ this.recoveries = 0;
1180
+ if (!this.posterUntilSeek) this.freezeHide();
1181
+ if (this.label === "loading") this.setLabel("playing");
1182
+ } else if (this.rwScrub) this.wdLastAt = Date.now();
1183
+ else if (Date.now() - this.wdLastAt > 4e3 && Date.now() > this.wdGrace) {
1184
+ this.wdLastAt = Date.now();
1185
+ const o = { ct: Math.round(v.currentTime * 10) / 10, paused: v.paused, muted: v.muted, rs: v.readyState, pf: q, w: v.videoWidth };
1186
+ try {
1187
+ s.pc?.getStats().then((st) => {
1188
+ st.forEach((r) => {
1189
+ if (r.type === "inbound-rtp" && (r.kind || r.mediaType) === "video") {
1190
+ o.recv = r.framesReceived;
1191
+ o.dec = r.framesDecoded;
1192
+ o.drop = r.framesDropped;
1193
+ o.pli = r.pliCount;
1194
+ }
1195
+ });
1196
+ rlog("stall-snap", o);
1197
+ }).catch(() => {
1198
+ });
1199
+ } catch {
1200
+ }
1201
+ this.relayRecover();
1202
+ return;
1203
+ }
1204
+ } else {
1205
+ this.wdLastCt = q;
1206
+ this.wdLastAt = Date.now();
1207
+ }
1208
+ const seq = this.cmdSeq;
1209
+ fetch(this.api.url(`api/relay-pos?session=${encodeURIComponent(s.id)}`), { cache: "no-store" }).then((r) => r.json()).then((d) => {
1210
+ if (seq !== this.cmdSeq) return;
1211
+ if (d && d.t > 0 && Date.now() - this.seekAt < 2500 && Math.abs(d.t - this.seekTarget) > 5e3) return;
1212
+ if (d && d.t > 0) {
1213
+ this.wdDead = 0;
1214
+ this.rwPosTs = d.t;
1215
+ this.rwPosAt = Date.now();
1216
+ if (this.posterUntilSeek && Math.abs(d.t - this.seekTarget) < 4e3 && !this.rwSeekBusy && !this.rwPending) this.freezeHide();
1217
+ this.emit();
1218
+ } else if (d && d.t <= 0) {
1219
+ if (++this.wdDead >= 3) {
1220
+ this.wdDead = 0;
1221
+ this.relayRecover();
1222
+ }
1223
+ }
1224
+ }).catch(() => {
1225
+ });
1226
+ }, 600);
1227
+ }
1228
+ stopRelayPoll() {
1229
+ this.cadStop();
1230
+ if (this.relayPoll) {
1231
+ clearInterval(this.relayPoll);
1232
+ this.relayPoll = void 0;
1233
+ }
1234
+ }
1235
+ cadStart() {
1236
+ this.cadStop();
1237
+ const C = this.cad;
1238
+ C.last = -1;
1239
+ C.frames0 = -1;
1240
+ C.stalls = 0;
1241
+ C.maxStall = 0;
1242
+ C.gapMax = 0;
1243
+ C.t0 = Date.now();
1244
+ C.t = window.setInterval(() => {
1245
+ if (!this.rw?.active || document.hidden) return;
1246
+ const q = this.presentedFrames(), now = Date.now();
1247
+ if (this.rwScrub) {
1248
+ C.last = q;
1249
+ C.lastNew = now;
1250
+ return;
1251
+ }
1252
+ if (C.frames0 < 0) {
1253
+ C.frames0 = q;
1254
+ C.last = q;
1255
+ C.lastNew = now;
1256
+ return;
1257
+ }
1258
+ if (q === C.frames0 && C.last === C.frames0) {
1259
+ C.lastNew = now;
1260
+ return;
1261
+ }
1262
+ if (q > C.last) {
1263
+ const gap = now - C.lastNew;
1264
+ if (gap > C.gapMax) C.gapMax = gap;
1265
+ if (gap >= 400) {
1266
+ C.stalls++;
1267
+ if (gap > C.maxStall) C.maxStall = gap;
1268
+ }
1269
+ C.last = q;
1270
+ C.lastNew = now;
1271
+ }
1272
+ if (now - C.t0 >= 3e4) {
1273
+ const rep = { sec: Math.round((now - C.t0) / 1e3), frames: C.last - C.frames0, stalls: C.stalls, maxStall: C.maxStall, gapMax: C.gapMax, w: this.v.videoWidth };
1274
+ C.t0 = now;
1275
+ C.frames0 = C.last;
1276
+ C.stalls = 0;
1277
+ C.maxStall = 0;
1278
+ C.gapMax = 0;
1279
+ const pc = this.rw?.pc;
1280
+ if (pc) pc.getStats().then((st) => {
1281
+ st.forEach((r) => {
1282
+ if (r.type === "inbound-rtp" && (r.kind || r.mediaType) === "video") {
1283
+ rep.lost = r.packetsLost;
1284
+ rep.jit = Math.round((r.jitter || 0) * 1e3);
1285
+ rep.recv = r.framesReceived;
1286
+ rep.dec = r.framesDecoded;
1287
+ rep.drop = r.framesDropped;
1288
+ }
1289
+ });
1290
+ rlog("cadence", rep);
1291
+ }).catch(() => rlog("cadence", rep));
1292
+ else rlog("cadence", rep);
1293
+ }
1294
+ }, 100);
1295
+ }
1296
+ cadStop() {
1297
+ if (this.cad.t) {
1298
+ clearInterval(this.cad.t);
1299
+ this.cad.t = 0;
1300
+ }
1301
+ }
1302
+ // ---- playAt: relay first, MSE/native fallback ------------------------------------------------
1303
+ playAt(ts, opts = {}) {
1304
+ const idx0 = this.clipIndexFor(ts);
1305
+ if (this.recWebrtcOk()) {
1306
+ this.chaseAbort();
1307
+ if (idx0 < 0) {
1308
+ this.setLabel("noRecording");
1309
+ return;
1310
+ }
1311
+ this.recPaused = false;
1312
+ const t0 = Math.max(ts, this.clips[idx0].startTime);
1313
+ if (this.rw?.active && this.rw.id) this.recRelaySeek(t0, this.rate || 1, opts.srate || 1);
1314
+ else if (this.rw?.active && !this.live) this.rwPending = { ts: t0, rate: this.rate || 1, srate: opts.srate || 1 };
1315
+ else {
1316
+ if (this.live) {
1317
+ this.freezeShow(true);
1318
+ this.exitLiveState();
1319
+ }
1320
+ this.recWebrtcStart(t0);
1321
+ }
1322
+ return;
1323
+ }
1324
+ const canChase = !opts.noChase && !this.live && this.M.active && !this.recPaused && this.currentTs() != null && idx0 >= 0;
1325
+ this.chaseAbort();
1326
+ if (canChase && !opts.scrub) {
1327
+ this.startChase(ts);
1328
+ return;
1329
+ }
1330
+ if (idx0 < 0) {
1331
+ this.setLabel("noRecording");
1332
+ return;
1333
+ }
1334
+ if (this.live) {
1335
+ this.freezeShow(true);
1336
+ this.exitLiveState();
1337
+ }
1338
+ this.recPaused = false;
1339
+ const clip = this.clips[idx0];
1340
+ if (ts < clip.startTime) ts = clip.startTime;
1341
+ this.playIndex = idx0;
1342
+ this.v.muted = opts.scrub ? true : !this.soundOn;
1343
+ if (this.mseSupported()) {
1344
+ const t = (ts - this.M.base) / 1e3;
1345
+ if (this.M.active && t >= 0 && (this.bufferedContains(t) || t <= this.M.end + 0.5 && this.M.end - t < 95)) {
1346
+ try {
1347
+ this.v.currentTime = t;
1348
+ } catch {
1349
+ }
1350
+ if (this.v.paused) this.safePlay();
1351
+ this.feed();
1352
+ } else {
1353
+ if (opts.scrub && Date.now() - this.lastLoad < 200) {
1354
+ this.pendingTs = ts;
1355
+ return;
1356
+ }
1357
+ this.lastLoad = Date.now();
1358
+ this.mseStart(idx0, (ts - clip.startTime) / 1e3);
1359
+ }
1360
+ } else {
1361
+ const off = Math.min(Math.max((ts - clip.startTime) / 1e3, 0), (clip.duration || 0) / 1e3);
1362
+ if (clip.id === this.curClipId && this.v.src) {
1363
+ try {
1364
+ this.v.currentTime = off;
1365
+ } catch {
1366
+ }
1367
+ if (this.v.paused) this.safePlay();
1368
+ } else {
1369
+ if (opts.scrub && Date.now() - this.lastLoad < 120) {
1370
+ this.pendingTs = ts;
1371
+ return;
1372
+ }
1373
+ this.freezeShow();
1374
+ this.lastLoad = Date.now();
1375
+ this.curClipId = clip.id;
1376
+ this.transport = "native";
1377
+ rlog("native-fallback", { clip: clip.id });
1378
+ this.v.src = this.api.url(`api/segment?id=${encodeURIComponent(clip.videoId || clip.id)}`);
1379
+ this.v.load();
1380
+ this.v.onloadedmetadata = () => {
1381
+ this.v.playbackRate = this.rate;
1382
+ try {
1383
+ this.v.currentTime = off;
1384
+ } catch {
1385
+ }
1386
+ this.safePlay();
1387
+ };
1388
+ }
1389
+ }
1390
+ this.setLabel(opts.scrub ? "scrub" : "playing");
1391
+ }
1392
+ // ---- MSE recorded ------------------------------------------------------------------------
1393
+ mseSupported() {
1394
+ try {
1395
+ return !!(this.api.corsMedia && MSCls && this.codecs && MSCls.isTypeSupported(`video/mp4; codecs="${this.codecs}"`));
1396
+ } catch {
1397
+ return false;
1398
+ }
1399
+ }
1400
+ mseTeardown() {
1401
+ const M = this.M;
1402
+ M.active = false;
1403
+ M.nextIdx = -1;
1404
+ M.feeding = false;
1405
+ M.end = 0;
1406
+ M.pendingSeek = null;
1407
+ M.q = [];
1408
+ M.segFirst = false;
1409
+ try {
1410
+ M.abort?.abort();
1411
+ } catch {
1412
+ }
1413
+ M.abort = null;
1414
+ if (M.sb && M.ms && M.ms.readyState === "open") {
1415
+ try {
1416
+ M.sb.abort();
1417
+ } catch {
1418
+ }
1419
+ }
1420
+ M.sb = null;
1421
+ M.ms = null;
1422
+ }
1423
+ bufferedContains(t) {
1424
+ try {
1425
+ const b = this.v.buffered;
1426
+ for (let i = 0; i < b.length; i++) if (t >= b.start(i) - 0.3 && t <= b.end(i) + 0.3) return true;
1427
+ } catch {
1428
+ }
1429
+ return false;
1430
+ }
1431
+ mseStart(idx, offSec) {
1432
+ this.freezeShow();
1433
+ this.mseTeardown();
1434
+ const M = this.M;
1435
+ const ms = new MSCls();
1436
+ M.ms = ms;
1437
+ M.active = true;
1438
+ M.base = this.clips[idx].startTime;
1439
+ M.nextIdx = idx;
1440
+ M.end = 0;
1441
+ M.mmsGo = true;
1442
+ this.transport = "mse";
1443
+ if (window.ManagedMediaSource && ms instanceof window.ManagedMediaSource) {
1444
+ try {
1445
+ this.v.disableRemotePlayback = true;
1446
+ } catch {
1447
+ }
1448
+ ms.addEventListener("startstreaming", () => {
1449
+ M.mmsGo = true;
1450
+ this.feed();
1451
+ });
1452
+ ms.addEventListener("endstreaming", () => {
1453
+ M.mmsGo = false;
1454
+ });
1455
+ }
1456
+ M.pendingSeek = offSec;
1457
+ const u = URL.createObjectURL(ms);
1458
+ ms.addEventListener("sourceopen", () => {
1459
+ URL.revokeObjectURL(u);
1460
+ if (M.ms !== ms) return;
1461
+ let sb;
1462
+ try {
1463
+ sb = ms.addSourceBuffer(`video/mp4; codecs="${this.codecs}"`);
1464
+ } catch {
1465
+ this.codecs = null;
1466
+ M.active = false;
1467
+ return;
1468
+ }
1469
+ sb.mode = "segments";
1470
+ sb.addEventListener("updateend", () => {
1471
+ if (M.pendingSeek != null && this.v.readyState >= 1) {
1472
+ const t0 = M.pendingSeek;
1473
+ M.pendingSeek = null;
1474
+ try {
1475
+ this.v.currentTime = t0;
1476
+ } catch {
1477
+ }
1478
+ }
1479
+ this.drain();
1480
+ if (!M.feeding && !M.q.length) this.feed();
1481
+ });
1482
+ sb.addEventListener("error", () => {
1483
+ M.active = false;
1484
+ });
1485
+ M.sb = sb;
1486
+ try {
1487
+ this.v.currentTime = offSec;
1488
+ } catch {
1489
+ }
1490
+ this.feed();
1491
+ });
1492
+ try {
1493
+ this.v.srcObject = null;
1494
+ } catch {
1495
+ }
1496
+ this.v.src = u;
1497
+ this.v.playbackRate = this.rate;
1498
+ this.safePlay();
1499
+ }
1500
+ drain() {
1501
+ const M = this.M;
1502
+ if (!M.active || !M.sb || M.sb.updating || !M.q.length) return;
1503
+ if (M.segFirst) {
1504
+ M.segFirst = false;
1505
+ try {
1506
+ M.sb.timestampOffset = M.segOff;
1507
+ } catch {
1508
+ try {
1509
+ M.sb.abort();
1510
+ M.sb.timestampOffset = M.segOff;
1511
+ } catch {
1512
+ M.active = false;
1513
+ return;
1514
+ }
1515
+ }
1516
+ }
1517
+ const buf = M.q.shift();
1518
+ try {
1519
+ M.sb.appendBuffer(buf);
1520
+ } catch {
1521
+ try {
1522
+ M.sb.abort();
1523
+ M.sb.appendBuffer(buf);
1524
+ } catch {
1525
+ M.active = false;
1526
+ }
1527
+ }
1528
+ }
1529
+ feed() {
1530
+ const M = this.M;
1531
+ if (!M.active || M.feeding) return;
1532
+ if (M.mmsGo === false) return;
1533
+ if (M.nextIdx < 0 || M.nextIdx >= this.clips.length) return;
1534
+ if (M.end - this.v.currentTime > 90) return;
1535
+ try {
1536
+ const b = this.v.buffered;
1537
+ if (b.length && this.v.currentTime - b.start(0) > 240 && M.sb && !M.sb.updating && !M.q.length) {
1538
+ M.sb.remove(0, this.v.currentTime - 120);
1539
+ return;
1540
+ }
1541
+ } catch {
1542
+ }
1543
+ const clip = this.clips[M.nextIdx], ms = M.ms;
1544
+ M.feeding = true;
1545
+ M.segFirst = true;
1546
+ M.segOff = (clip.startTime - M.base) / 1e3;
1547
+ M.abort = new AbortController();
1548
+ fetch(this.api.url(`api/segment?id=${encodeURIComponent(clip.videoId || clip.id)}`), { credentials: "same-origin", signal: M.abort.signal }).then((r) => {
1549
+ if (!r.ok || !r.body) throw new Error(String(r.status));
1550
+ const rd = r.body.getReader();
1551
+ const loop = () => {
1552
+ rd.read().then((x) => {
1553
+ if (M.ms !== ms || !M.active) return;
1554
+ if (x.done) {
1555
+ M.feeding = false;
1556
+ M.nextIdx++;
1557
+ M.end = M.segOff + (clip.duration || 6e4) / 1e3;
1558
+ if (!M.q.length) this.feed();
1559
+ return;
1560
+ }
1561
+ M.q.push(x.value);
1562
+ this.drain();
1563
+ loop();
1564
+ }).catch(() => {
1565
+ if (M.ms === ms) M.feeding = false;
1566
+ });
1567
+ };
1568
+ loop();
1569
+ }).catch(() => {
1570
+ if (M.ms !== ms) return;
1571
+ M.feeding = false;
1572
+ M.nextIdx++;
1573
+ this.feed();
1574
+ });
1575
+ }
1576
+ handleStall() {
1577
+ if (this.live || !this.M.active || this.v.paused) return;
1578
+ const t = this.v.currentTime;
1579
+ try {
1580
+ const b = this.v.buffered;
1581
+ for (let i = 0; i < b.length; i++) if (b.start(i) > t && b.start(i) - t < 60) {
1582
+ this.v.currentTime = b.start(i) + 0.05;
1583
+ return;
1584
+ }
1585
+ } catch {
1586
+ }
1587
+ if (this.M.nextIdx >= this.clips.length) this.opts.onClipsRefresh().then((c) => {
1588
+ const cur = this.clips[this.M.nextIdx - 1];
1589
+ this.clips = c;
1590
+ if (cur) {
1591
+ const i = c.findIndex((x) => x.id === cur.id);
1592
+ if (i >= 0) this.M.nextIdx = i + 1;
1593
+ }
1594
+ this.feed();
1595
+ }).catch(() => {
1596
+ });
1597
+ else this.feed();
1598
+ }
1599
+ // trick-play chase inside the MSE buffer
1600
+ chaseAbort() {
1601
+ if (!this.CH.active) return;
1602
+ this.CH.active = false;
1603
+ if (this.CH.timer) {
1604
+ clearInterval(this.CH.timer);
1605
+ this.CH.timer = 0;
1606
+ }
1607
+ this.v.playbackRate = this.rate;
1608
+ this.v.muted = !this.soundOn;
1609
+ }
1610
+ endChase() {
1611
+ const tgt = this.CH.target;
1612
+ this.chaseAbort();
1613
+ try {
1614
+ this.v.currentTime = (tgt - this.M.base) / 1e3;
1615
+ } catch {
1616
+ }
1617
+ this.setLabel("playing");
1618
+ if (this.v.paused) this.safePlay();
1619
+ }
1620
+ startChase(target) {
1621
+ this.chaseAbort();
1622
+ this.freezeHide();
1623
+ this.CH.active = true;
1624
+ this.CH.target = target;
1625
+ try {
1626
+ this.v.pause();
1627
+ } catch {
1628
+ }
1629
+ this.v.muted = true;
1630
+ this.setLabel("scrub");
1631
+ this.CH.timer = window.setInterval(() => {
1632
+ if (!this.CH.active) return;
1633
+ const c = this.currentTs();
1634
+ if (c == null) {
1635
+ this.endChase();
1636
+ return;
1637
+ }
1638
+ const rem = this.CH.target - c;
1639
+ if (Math.abs(rem) <= 250) {
1640
+ this.endChase();
1641
+ return;
1642
+ }
1643
+ let step = rem * 0.2;
1644
+ if (Math.abs(step) < 180) step = rem > 0 ? 180 : -180;
1645
+ const nt = c + step, tt = (nt - this.M.base) / 1e3;
1646
+ if (this.M.active && this.bufferedContains(tt)) {
1647
+ try {
1648
+ this.v.currentTime = tt;
1649
+ } catch {
1650
+ }
1651
+ this.feed();
1652
+ } else {
1653
+ this.endChase();
1654
+ this.playAt(this.CH.target, { noChase: true });
1655
+ }
1656
+ }, 50);
1657
+ }
1658
+ // ---- controls -----------------------------------------------------------------------------
1659
+ togglePlayPause() {
1660
+ if (this.live) return;
1661
+ if (this.CH.active) {
1662
+ this.chaseAbort();
1663
+ try {
1664
+ this.v.pause();
1665
+ } catch {
1666
+ }
1667
+ this.emit();
1668
+ return;
1669
+ }
1670
+ if (this.recPaused) {
1671
+ this.recPaused = false;
1672
+ const t = this.recPausedTs ?? (this.currentTs() ?? Date.now() - 1e3);
1673
+ this.playAt(t, {});
1674
+ return;
1675
+ }
1676
+ if (this.rw?.active) {
1677
+ this.recPausedTs = this.currentTs();
1678
+ this.freezeShow();
1679
+ if (this.freezeT) {
1680
+ clearTimeout(this.freezeT);
1681
+ this.freezeT = 0;
1682
+ }
1683
+ this.recWebrtcTeardown();
1684
+ try {
1685
+ this.v.pause();
1686
+ } catch {
1687
+ }
1688
+ this.recPaused = true;
1689
+ this.transport = "none";
1690
+ this.setLabel("paused");
1691
+ return;
1692
+ }
1693
+ if (this.v.paused) this.safePlay();
1694
+ else this.v.pause();
1695
+ }
1696
+ skip(ms) {
1697
+ const ts = this.live ? Date.now() + ms : (this.currentTs() ?? Date.now()) + ms;
1698
+ if (this.live && ms > 0) return;
1699
+ this.playAt(ts, {});
1700
+ }
1701
+ cycleSpeed() {
1702
+ if (this.live) return this.rate;
1703
+ const rates = [1, 2, 4, 8];
1704
+ this.rate = rates[(rates.indexOf(this.rate) + 1) % rates.length] ?? 1;
1705
+ if (this.rw?.active && this.rw.id) {
1706
+ const c = this.currentTs();
1707
+ this.recRelaySeek(c ?? this.rwStartMs, this.rate);
1708
+ this.v.muted = !this.soundOn || this.rate !== 1;
1709
+ } else this.v.playbackRate = this.rate;
1710
+ this.emit();
1711
+ return this.rate;
1712
+ }
1713
+ setSound(on) {
1714
+ this.soundOn = on;
1715
+ this.v.muted = !on || !!this.rw?.active && this.rate !== 1;
1716
+ if (on && this.v.paused && !this.recPaused) this.safePlay();
1717
+ this.emit();
1718
+ }
1719
+ snapshot() {
1720
+ try {
1721
+ const src = this.live && !this.img.classList.contains("hidden") ? this.img : this.v;
1722
+ const w = src.videoWidth || src.naturalWidth || 1280, h = src.videoHeight || src.naturalHeight || 720;
1723
+ const cv = document.createElement("canvas");
1724
+ cv.width = w;
1725
+ cv.height = h;
1726
+ cv.getContext("2d").drawImage(src, 0, 0, w, h);
1727
+ cv.toBlob((b) => {
1728
+ if (!b) return;
1729
+ const a = document.createElement("a");
1730
+ a.href = URL.createObjectURL(b);
1731
+ const ts = this.currentTs() ?? Date.now();
1732
+ const d = new Date(ts);
1733
+ a.download = `${(this.camName || "snapshot").replace(/[^\w.-]+/g, "_")}_${String(d.getHours()).padStart(2, "0")}${String(d.getMinutes()).padStart(2, "0")}${String(d.getSeconds()).padStart(2, "0")}.jpg`;
1734
+ document.body.appendChild(a);
1735
+ a.click();
1736
+ a.remove();
1737
+ setTimeout(() => URL.revokeObjectURL(a.href), 4e3);
1738
+ }, "image/jpeg", 0.92);
1739
+ } catch {
1740
+ }
1741
+ }
1742
+ capLog(tag, extra) {
1743
+ try {
1744
+ const v = this.v, st = this.stage;
1745
+ rlog(tag, { fsReq: !!st.requestFullscreen, wkFs: !!st.webkitRequestFullscreen, vEnter: !!v.webkitEnterFullscreen, pipReq: !!v.requestPictureInPicture, wkPip: !!(v.webkitSupportsPresentationMode && v.webkitSupportsPresentationMode("picture-in-picture")), rs: v.readyState, vw: v.videoWidth, ...extra || {} });
1746
+ } catch {
1747
+ }
1748
+ }
1749
+ pip() {
1750
+ const v = this.v;
1751
+ this.capLog("pip-btn");
1752
+ try {
1753
+ if (document.pictureInPictureElement || v.webkitPresentationMode === "picture-in-picture") {
1754
+ if (document.pictureInPictureElement) document.exitPictureInPicture().catch(() => {
1755
+ });
1756
+ else v.webkitSetPresentationMode("inline");
1757
+ return;
1758
+ }
1759
+ if (this.v.classList.contains("hidden")) return;
1760
+ const wk = () => {
1761
+ try {
1762
+ if (v.webkitSupportsPresentationMode && v.webkitSupportsPresentationMode("picture-in-picture")) v.webkitSetPresentationMode("picture-in-picture");
1763
+ else this.capLog("pip-unsupported");
1764
+ } catch (e) {
1765
+ this.capLog("pip-fail", { e: String(e?.name || e) });
1766
+ }
1767
+ };
1768
+ if (v.requestPictureInPicture) v.requestPictureInPicture().catch((e) => {
1769
+ this.capLog("pip-fail", { e: String(e?.name || e) });
1770
+ wk();
1771
+ });
1772
+ else wk();
1773
+ } catch (e) {
1774
+ this.capLog("pip-fail", { e: String(e?.name || e) });
1775
+ }
1776
+ }
1777
+ fullscreen() {
1778
+ const st = this.stage, v = this.v, d = document;
1779
+ this.capLog("fs-btn");
1780
+ try {
1781
+ if (d.fullscreenElement || d.webkitFullscreenElement) {
1782
+ (d.exitFullscreen || d.webkitExitFullscreen).call(d);
1783
+ return;
1784
+ }
1785
+ if (st.requestFullscreen) st.requestFullscreen().catch((e) => this.capLog("fs-fail", { e: String(e?.name || e) }));
1786
+ else if (st.webkitRequestFullscreen) st.webkitRequestFullscreen();
1787
+ else if (v.webkitEnterFullscreen && !this.v.classList.contains("hidden")) v.webkitEnterFullscreen();
1788
+ else this.capLog("fs-unsupported");
1789
+ } catch (e) {
1790
+ this.capLog("fs-fail", { e: String(e?.name || e) });
1791
+ }
1792
+ }
1793
+ // ---- scrub (timeline gesture) ------------------------------------------------------------
1794
+ /** first gesture: scrub profile on (relay); MSE: freeze + pause */
1795
+ scrubBegin() {
1796
+ this.chaseAbort();
1797
+ if (!this.rw?.active) {
1798
+ this.freezeShow();
1799
+ if (!this.live) {
1800
+ try {
1801
+ this.v.pause();
1802
+ } catch {
1803
+ }
1804
+ }
1805
+ }
1806
+ this.recRelayScrub(true);
1807
+ }
1808
+ /** scroll position + velocity (timeline-ms per wall-s) while the gesture runs */
1809
+ scrubMove(_centerTs, vel, smoothTs) {
1810
+ if (!this.rw?.active) {
1811
+ this.freezeHold();
1812
+ return;
1813
+ }
1814
+ if (!this.rw.id) return;
1815
+ let r = vel / 1e3;
1816
+ if (Math.abs(r) < 0.1) return;
1817
+ r = Math.min(3e3, Math.max(-3e3, r));
1818
+ r = Math.abs(r) >= 10 ? Math.round(r) : Math.round(r * 10) / 10;
1819
+ const tgt = this.clampRange(smoothTs), cur = this.currentTs();
1820
+ const drift = cur == null ? null : tgt - cur;
1821
+ if (drift == null || Math.abs(drift) > Math.max(4500, 1200 * Math.abs(r))) this.scrubSeek(tgt, r);
1822
+ else this.recRelayRate(r);
1823
+ }
1824
+ /** hold / release: land exactly on the centre at 1× (or go live near now) */
1825
+ scrubSeek(ts, srate = 1) {
1826
+ if (this.nearNow(ts)) {
1827
+ this.goLive();
1828
+ return;
1829
+ }
1830
+ this.playAt(this.clampRange(ts), { scrub: true, srate });
1831
+ }
1832
+ /** gesture settled → normal profile, base rate */
1833
+ scrubIdle() {
1834
+ this.recRelayScrub(false);
1835
+ }
1836
+ // ---- visibility ---------------------------------------------------------------------------
1837
+ hiddenTs = null;
1838
+ onVisibility() {
1839
+ if (document.visibilityState === "hidden") {
1840
+ if (this.live) {
1841
+ this.hiddenTs = null;
1842
+ this.webrtcTeardown();
1843
+ this.liveTeardown();
1844
+ if (this.transport === "mjpeg") this.img.removeAttribute("src");
1845
+ } else if (this.rw?.active) {
1846
+ this.hiddenTs = this.currentTs();
1847
+ this.freezeShow(true);
1848
+ this.recWebrtcTeardown();
1849
+ }
1850
+ } else {
1851
+ if (this.live) this.goLive();
1852
+ else if (this.hiddenTs != null && !this.recPaused) {
1853
+ const t = this.hiddenTs;
1854
+ this.hiddenTs = null;
1855
+ this.playAt(t, {});
1856
+ }
1857
+ }
1858
+ }
1859
+ };
1860
+ export {
1861
+ PlayerController,
1862
+ WebRtcSession,
1863
+ mobileClient,
1864
+ rlog,
1865
+ setRlogClient
1866
+ };
1867
+ //# sourceMappingURL=index.js.map