@flyfish-dev/rtsp-player 0.1.23

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,922 @@
1
+ // RTSP SDK 0.1.23 - generated by scripts/build-sdk.mjs
2
+ const DEFAULT_TAG_NAME = "rtsp-player";
3
+ const DEFAULT_WIDTH = "640px";
4
+ const DEFAULT_HEIGHT = "360px";
5
+
6
+ const globalConfig = {
7
+ extensionId: "",
8
+ tagName: DEFAULT_TAG_NAME,
9
+ runtime: "extension",
10
+ };
11
+
12
+ const frameOwners = new WeakMap();
13
+ const MAX_RECOVERY_ATTEMPTS = 6;
14
+ const RECOVERY_BASE_DELAY_MS = 700;
15
+ const RECOVERY_MAX_DELAY_MS = 8000;
16
+ const WEBSOCKET_FIRST_FRAME_TIMEOUT_MS = 45000;
17
+ const WEBSOCKET_KEYFRAME_NOTICE_MS = 3000;
18
+ const WEBRTC_FIRST_FRAME_TIMEOUT_MS = 5000;
19
+
20
+ function currentScriptExtensionId() {
21
+ if (typeof document === "undefined") return "";
22
+ const script = document.currentScript;
23
+ return script?.dataset?.extensionId || "";
24
+ }
25
+
26
+ function normalizeExtensionId(value) {
27
+ return String(value || "").trim().replace(/^chrome-extension:\/\//, "").replace(/\/.*$/, "");
28
+ }
29
+
30
+ function extensionOrigin(extensionId) {
31
+ const id = normalizeExtensionId(extensionId);
32
+ return id ? `chrome-extension://${id}` : "";
33
+ }
34
+
35
+ function toCssSize(value, fallback) {
36
+ if (value === undefined || value === null || value === "") return fallback;
37
+ const text = String(value);
38
+ return /^\d+$/.test(text) ? `${text}px` : text;
39
+ }
40
+
41
+ function setBooleanAttribute(el, name, enabled) {
42
+ if (enabled === undefined) return;
43
+ if (enabled) el.setAttribute(name, "");
44
+ else el.removeAttribute(name);
45
+ }
46
+
47
+ function normalizeRuntime(value) {
48
+ const text = String(value || "").trim().toLowerCase();
49
+ if (text === "desktop" || text === "auto" || text === "extension") return text;
50
+ return "extension";
51
+ }
52
+
53
+ function normalizeMediaTransport(value) {
54
+ const text = String(value || "").trim().toLowerCase();
55
+ if (text === "webrtc" || text === "ws-annexb" || text === "auto") return text;
56
+ return "auto";
57
+ }
58
+
59
+ function normalizeCodec(value) {
60
+ const text = String(value || "").trim().toLowerCase();
61
+ if (text === "h265" || text === "hevc") return "h265";
62
+ if (text === "h264" || text === "avc") return "h264";
63
+ return "auto";
64
+ }
65
+
66
+ function desktopBridge() {
67
+ if (typeof window === "undefined") return null;
68
+ return window.rtspNative || window.__RTSP_DESKTOP__ || null;
69
+ }
70
+
71
+ function canUseDesktopRuntime() {
72
+ return Boolean(desktopBridge()?.startStream);
73
+ }
74
+
75
+ function dispatch(el, type, detail = {}) {
76
+ el.dispatchEvent(new CustomEvent(type, { detail, bubbles: true, composed: true }));
77
+ }
78
+
79
+ export const RTSP_PLAYER_VERSION = "0.1.23";
80
+
81
+ export function configureRTSP(options = {}) {
82
+ if (options.extensionId !== undefined) {
83
+ globalConfig.extensionId = normalizeExtensionId(options.extensionId);
84
+ }
85
+ if (options.tagName) {
86
+ globalConfig.tagName = String(options.tagName);
87
+ }
88
+ if (options.runtime) {
89
+ globalConfig.runtime = normalizeRuntime(options.runtime);
90
+ }
91
+ return { ...globalConfig };
92
+ }
93
+
94
+ export async function probeRTSPCapabilities(codec = "auto") {
95
+ const want = normalizeCodec(codec);
96
+ const capabilities = {
97
+ desktopRuntime: canUseDesktopRuntime(),
98
+ webcodecs: "VideoDecoder" in globalThis,
99
+ webrtc: "RTCPeerConnection" in globalThis,
100
+ h264WebRTC: false,
101
+ h265WebRTC: false,
102
+ h264WebCodecs: false,
103
+ h265WebCodecs: false,
104
+ };
105
+ try {
106
+ const receiverCaps = globalThis.RTCRtpReceiver?.getCapabilities?.("video");
107
+ const names = (receiverCaps?.codecs || []).map((item) => String(item.mimeType || "").toLowerCase());
108
+ capabilities.h264WebRTC = names.includes("video/h264");
109
+ capabilities.h265WebRTC = names.includes("video/h265") || names.includes("video/hevc");
110
+ } catch {}
111
+ if (capabilities.webcodecs) {
112
+ try {
113
+ capabilities.h264WebCodecs = Boolean((await VideoDecoder.isConfigSupported({
114
+ codec: "avc1.42E01E",
115
+ hardwareAcceleration: "prefer-hardware",
116
+ optimizeForLatency: true,
117
+ })).supported);
118
+ } catch {}
119
+ try {
120
+ capabilities.h265WebCodecs = Boolean((await VideoDecoder.isConfigSupported({
121
+ codec: "hvc1.1.6.L93.B0",
122
+ hardwareAcceleration: "prefer-hardware",
123
+ optimizeForLatency: true,
124
+ })).supported);
125
+ } catch {}
126
+ }
127
+ capabilities.requestedCodec = want;
128
+ return capabilities;
129
+ }
130
+
131
+ export function defineRTSPPlayer(tagName = globalConfig.tagName || DEFAULT_TAG_NAME, options = {}) {
132
+ if (typeof window === "undefined" || typeof customElements === "undefined") {
133
+ return undefined;
134
+ }
135
+
136
+ configureRTSP(options);
137
+ const name = String(tagName || DEFAULT_TAG_NAME);
138
+ const existing = customElements.get(name);
139
+ if (existing) return existing;
140
+
141
+ class RTSPPlayerElement extends HTMLElement {
142
+ static get observedAttributes() {
143
+ return [
144
+ "url",
145
+ "src",
146
+ "width",
147
+ "height",
148
+ "autoplay",
149
+ "controls",
150
+ "muted",
151
+ "transport",
152
+ "media-transport",
153
+ "rtsp-transport",
154
+ "codec",
155
+ "runtime",
156
+ "extension-id",
157
+ ];
158
+ }
159
+
160
+ constructor() {
161
+ super();
162
+ this._iframe = null;
163
+ this._loaded = false;
164
+ this._player = null;
165
+ this._pc = null;
166
+ this._video = null;
167
+ this._mode = "";
168
+ this._streamId = "";
169
+ this._streamToken = "";
170
+ this._playGeneration = 0;
171
+ this._recoveryAttempts = 0;
172
+ this._recoveryTimer = 0;
173
+ this._userStopped = false;
174
+ this.attachShadow({ mode: "open" });
175
+ }
176
+
177
+ connectedCallback() {
178
+ this._render();
179
+ }
180
+
181
+ disconnectedCallback() {
182
+ this.stop();
183
+ if (this._iframe?.contentWindow) {
184
+ frameOwners.delete(this._iframe.contentWindow);
185
+ }
186
+ }
187
+
188
+ attributeChangedCallback(name) {
189
+ if (name === "runtime" || name === "extension-id") {
190
+ this.stop();
191
+ this._iframe = null;
192
+ this._loaded = false;
193
+ if (this.shadowRoot) this.shadowRoot.innerHTML = "";
194
+ this._render();
195
+ return;
196
+ }
197
+ this._resize();
198
+ if (this._mode === "extension") this._sendInit();
199
+ else if (this.hasAttribute("autoplay") && (name === "url" || name === "src")) this.play();
200
+ }
201
+
202
+ play(url) {
203
+ if (url) this.setAttribute("url", url);
204
+ if (this._mode === "extension") this._sendInit();
205
+ else this._startDesktop();
206
+ }
207
+
208
+ stop() {
209
+ this._userStopped = true;
210
+ this._playGeneration += 1;
211
+ this._clearRecoveryTimer();
212
+ this._recoveryAttempts = 0;
213
+ const streamId = this._streamId;
214
+ this._streamId = "";
215
+ this._streamToken = "";
216
+ if (this._iframe?.contentWindow) {
217
+ this._iframe.contentWindow.postMessage({ type: "RTSP_PLAYER_STOP" }, this._origin());
218
+ }
219
+ if (this._player) {
220
+ this._player.close();
221
+ this._player = null;
222
+ }
223
+ if (this._pc) {
224
+ try { this._pc.close(); } catch {}
225
+ this._pc = null;
226
+ }
227
+ if (this._video) {
228
+ try { this._video.srcObject = null; } catch {}
229
+ this._video.remove();
230
+ this._video = null;
231
+ }
232
+ const bridge = desktopBridge();
233
+ if (streamId && bridge?.stopStream) {
234
+ bridge.stopStream({ streamId }).catch?.(() => {});
235
+ }
236
+ }
237
+
238
+ async capabilities() {
239
+ return probeRTSPCapabilities(this._codec());
240
+ }
241
+
242
+ _runtime() {
243
+ const requested = normalizeRuntime(this.getAttribute("runtime") || globalConfig.runtime);
244
+ if (requested === "auto") return canUseDesktopRuntime() ? "desktop" : "extension";
245
+ return requested;
246
+ }
247
+
248
+ _extensionId() {
249
+ return normalizeExtensionId(
250
+ this.getAttribute("extension-id") ||
251
+ globalConfig.extensionId ||
252
+ window.RTSP_EXTENSION_ID ||
253
+ window.RTSP_WEB_PLAYER_EXTENSION_ID ||
254
+ currentScriptExtensionId(),
255
+ );
256
+ }
257
+
258
+ _origin() {
259
+ return extensionOrigin(this._extensionId());
260
+ }
261
+
262
+ _url() {
263
+ return this.getAttribute("url") || this.getAttribute("src") || "";
264
+ }
265
+
266
+ _rtspTransport() {
267
+ const explicit = this.getAttribute("rtsp-transport");
268
+ const legacy = this.getAttribute("transport");
269
+ if (explicit) return explicit;
270
+ if (legacy === "tcp" || legacy === "udp") return legacy;
271
+ return "tcp";
272
+ }
273
+
274
+ _mediaTransport() {
275
+ return normalizeMediaTransport(this.getAttribute("media-transport") || this.getAttribute("transport") || "auto");
276
+ }
277
+
278
+ _codec() {
279
+ return normalizeCodec(this.getAttribute("codec") || "auto");
280
+ }
281
+
282
+ _render() {
283
+ if (!this.shadowRoot || this.shadowRoot.innerHTML) return;
284
+ this._resize();
285
+ const runtime = this._runtime();
286
+ if (runtime === "desktop") this._renderDesktop();
287
+ else this._renderExtension();
288
+ }
289
+
290
+ _renderExtension() {
291
+ this._mode = "extension";
292
+ this.shadowRoot.innerHTML = `
293
+ <style>
294
+ :host{display:inline-block;background:#050505;min-width:160px;min-height:90px;contain:content;}
295
+ .rtsp-host{width:100%;height:100%;background:#050505;position:relative;overflow:hidden;border-radius:6px;}
296
+ iframe{width:100%;height:100%;border:0;background:#050505;display:block;}
297
+ .missing{height:100%;min-height:120px;display:flex;align-items:center;justify-content:center;background:#151515;color:#ddd;font:12px system-ui,sans-serif;text-align:center;padding:12px;box-sizing:border-box;}
298
+ .gateway-guide{height:100%;min-height:170px;display:grid;align-content:center;gap:10px;padding:18px;background:#111312;color:#e9ede8;font:13px system-ui,sans-serif;box-sizing:border-box;}
299
+ .gateway-guide strong{color:#f4f5f2;font-size:15px;}
300
+ .gateway-guide span{color:#b6bbb4;line-height:1.5;}
301
+ .gateway-guide a{display:inline-flex;width:max-content;max-width:100%;align-items:center;min-height:34px;padding:0 10px;border:1px solid #435048;border-radius:6px;background:#223423;color:#ecfff0;text-decoration:none;}
302
+ </style>
303
+ <div class="rtsp-host"></div>
304
+ `;
305
+ const host = this.shadowRoot.querySelector(".rtsp-host");
306
+ const origin = this._origin();
307
+ if (!origin) {
308
+ host.innerHTML = this._gatewayGuideHTML(
309
+ "需要安装 RTSP Gateway",
310
+ "普通网页不能直接访问 rtsp://。请先安装 RTSP Gateway 与 Chrome 扩展,或在 Electron/Tauri 中使用 desktop runtime bridge。",
311
+ );
312
+ return;
313
+ }
314
+ const iframe = document.createElement("iframe");
315
+ iframe.src = `${origin}/player/player.html`;
316
+ iframe.allow = "autoplay; fullscreen";
317
+ iframe.referrerPolicy = "no-referrer";
318
+ iframe.addEventListener("load", () => {
319
+ this._loaded = true;
320
+ frameOwners.set(iframe.contentWindow, this);
321
+ this._sendInit();
322
+ });
323
+ host.appendChild(iframe);
324
+ this._iframe = iframe;
325
+ }
326
+
327
+ _renderDesktop() {
328
+ this._mode = "desktop";
329
+ this.shadowRoot.innerHTML = `
330
+ <style>
331
+ :host{display:inline-block;background:#050505;min-width:160px;min-height:90px;contain:content;}
332
+ .rtsp-host{width:100%;height:100%;background:#050505;position:relative;overflow:hidden;border-radius:6px;}
333
+ canvas{width:100%;height:100%;display:block;background:#050505;}
334
+ .status{position:absolute;left:10px;right:10px;bottom:10px;padding:8px 10px;border-radius:6px;background:rgba(0,0,0,.62);color:#f5f5f5;font:12px system-ui,sans-serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
335
+ .status.ok{color:#9ad29d}.status.error{color:#e08d7a}
336
+ .gateway-guide{position:absolute;inset:0;display:grid;align-content:center;gap:10px;padding:18px;background:#111312;color:#e9ede8;font:13px system-ui,sans-serif;box-sizing:border-box;}
337
+ .gateway-guide strong{color:#f4f5f2;font-size:15px;}
338
+ .gateway-guide span{color:#b6bbb4;line-height:1.5;}
339
+ .gateway-guide a{display:inline-flex;width:max-content;max-width:100%;align-items:center;min-height:34px;padding:0 10px;border:1px solid #435048;border-radius:6px;background:#223423;color:#ecfff0;text-decoration:none;}
340
+ </style>
341
+ <div class="rtsp-host">
342
+ <canvas part="canvas"></canvas>
343
+ <div class="status" part="status">Desktop runtime ready.</div>
344
+ </div>
345
+ `;
346
+ if (this.hasAttribute("autoplay") && this._url()) this._startDesktop();
347
+ }
348
+
349
+ _resize() {
350
+ this.style.width = toCssSize(this.getAttribute("width") || this.style.width, DEFAULT_WIDTH);
351
+ this.style.height = toCssSize(this.getAttribute("height") || this.style.height, DEFAULT_HEIGHT);
352
+ }
353
+
354
+ _sendInit() {
355
+ const origin = this._origin();
356
+ if (!this._loaded || !this._iframe?.contentWindow || !origin) return;
357
+ this._iframe.contentWindow.postMessage({
358
+ type: "RTSP_PLAYER_INIT",
359
+ url: this._url(),
360
+ autoplay: this.hasAttribute("autoplay"),
361
+ controls: this.hasAttribute("controls"),
362
+ muted: this.hasAttribute("muted"),
363
+ transport: this._rtspTransport(),
364
+ rtspTransport: this._rtspTransport(),
365
+ mediaTransport: this._mediaTransport(),
366
+ codec: this._codec(),
367
+ }, origin);
368
+ }
369
+
370
+ async _startDesktop() {
371
+ const bridge = desktopBridge();
372
+ const url = this._url().trim();
373
+ if (!bridge?.startStream) {
374
+ this._showGatewayGuide(
375
+ "未检测到 RTSP Gateway bridge",
376
+ "Web 组件免插件接入需要先安装 Gateway,并在页面中注入 window.rtspNative 或 window.__RTSP_DESKTOP__。",
377
+ );
378
+ this._status("Desktop runtime bridge is not available.", "error");
379
+ dispatch(this, "error", { error: "Desktop runtime bridge is not available." });
380
+ return;
381
+ }
382
+ if (!/^rtsps?:\/\//i.test(url)) {
383
+ this._status("RTSP URL must start with rtsp:// or rtsps://.", "error");
384
+ return;
385
+ }
386
+ this._stopMediaOnly();
387
+ this._userStopped = false;
388
+ this._clearRecoveryTimer();
389
+ this._recoveryAttempts = 0;
390
+ const generation = ++this._playGeneration;
391
+ dispatch(this, "starting", { runtime: "desktop", transport: this._mediaTransport(), codec: this._codec() });
392
+ this._status("Starting desktop runtime...");
393
+ const mediaTransport = this._mediaTransport();
394
+ const codec = this._codec();
395
+ if ((mediaTransport === "auto" || mediaTransport === "webrtc") && await this._canAttemptWebRTC(codec)) {
396
+ const started = await this._startWebRTC(bridge, url, codec, generation);
397
+ if (started) return;
398
+ }
399
+ await this._startWebSocket(bridge, url, codec, generation);
400
+ }
401
+
402
+ async _canAttemptWebRTC(codec) {
403
+ if (!("RTCPeerConnection" in window)) return false;
404
+ const caps = await probeRTSPCapabilities(codec);
405
+ if (codec === "h265") return caps.h265WebRTC;
406
+ if (codec === "h264") return caps.h264WebRTC;
407
+ return caps.h265WebRTC || caps.h264WebRTC;
408
+ }
409
+
410
+ async _startWebRTC(bridge, url, codec, generation) {
411
+ if (!bridge.createWebRTCOffer || !bridge.startStream) return false;
412
+ const lifecycle = await bridge.startStream({
413
+ url,
414
+ codec,
415
+ transport: this._rtspTransport(),
416
+ mediaTransport: "webrtc",
417
+ origin: location.origin,
418
+ });
419
+ if (!lifecycle?.ok || !lifecycle.streamId) {
420
+ this._status(lifecycle?.error || "Desktop runtime WebRTC start failed.");
421
+ return false;
422
+ }
423
+ this._streamId = lifecycle.streamId || "";
424
+ this._streamToken = lifecycle.streamToken || "";
425
+ const pc = new RTCPeerConnection({ iceServers: [] });
426
+ this._pc = pc;
427
+ const stream = new MediaStream();
428
+ let gotTrack = false;
429
+ pc.addTransceiver("video", { direction: "recvonly" });
430
+ pc.addEventListener("connectionstatechange", () => {
431
+ const state = pc.connectionState;
432
+ if (gotTrack && (state === "failed" || state === "disconnected")) {
433
+ this._scheduleRecovery(generation, `webrtc-connection-${state}`, { codec, state });
434
+ }
435
+ });
436
+ pc.ontrack = (event) => {
437
+ gotTrack = true;
438
+ event.track?.addEventListener?.("ended", () => this._scheduleRecovery(generation, "webrtc-track-ended", { codec }));
439
+ stream.addTrack(event.track);
440
+ this._attachVideoStream(stream, generation);
441
+ this._status(`WebRTC ${codec || "auto"} ready.`, "ok");
442
+ dispatch(this, "ready", { runtime: "desktop", mediaTransport: "webrtc", codec });
443
+ };
444
+ const offer = await pc.createOffer();
445
+ await pc.setLocalDescription(offer);
446
+ await waitForIceGathering(pc);
447
+ const response = await bridge.createWebRTCOffer({
448
+ url,
449
+ codec,
450
+ origin: location.origin,
451
+ streamId: this._streamId,
452
+ streamToken: this._streamToken,
453
+ offer: pc.localDescription?.sdp || offer.sdp,
454
+ });
455
+ if (!response?.ok || !response.answer) {
456
+ this._status(response?.error || "WebRTC unavailable, falling back to WebSocket.");
457
+ try { pc.close(); } catch {}
458
+ this._pc = null;
459
+ await bridge.stopStream?.({ streamId: this._streamId }).catch?.(() => {});
460
+ this._streamId = "";
461
+ this._streamToken = "";
462
+ return false;
463
+ }
464
+ await pc.setRemoteDescription({ type: "answer", sdp: response.answer });
465
+ codec = response.codec || codec;
466
+ await delay(WEBRTC_FIRST_FRAME_TIMEOUT_MS);
467
+ if (gotTrack) return true;
468
+ this._status("WebRTC negotiated but no video arrived; falling back to WebSocket.");
469
+ try { pc.close(); } catch {}
470
+ this._pc = null;
471
+ await bridge.stopStream?.({ streamId: this._streamId }).catch?.(() => {});
472
+ this._streamId = "";
473
+ this._streamToken = "";
474
+ return false;
475
+ }
476
+
477
+ async _startWebSocket(bridge, url, codec, generation) {
478
+ const response = await bridge.startStream({
479
+ url,
480
+ codec,
481
+ transport: this._rtspTransport(),
482
+ mediaTransport: "ws-annexb",
483
+ origin: location.origin,
484
+ });
485
+ if (!response?.ok || !response.wsUrl) {
486
+ const error = response?.error || "Desktop runtime start failed.";
487
+ if (this._recoveryAttempts > 0) {
488
+ this._scheduleRecovery(generation, "desktop-start-failed", { error });
489
+ return;
490
+ }
491
+ this._status(error, "error");
492
+ dispatch(this, "error", { error });
493
+ return;
494
+ }
495
+ this._streamId = response.streamId || "";
496
+ this._streamToken = response.streamToken || "";
497
+ const canvas = this.shadowRoot.querySelector("canvas");
498
+ this._player = new AnnexBWebCodecsPlayer(canvas, {
499
+ onStatus: (message, tone) => this._status(message, tone),
500
+ onReady: () => dispatch(this, "ready", { runtime: "desktop", mediaTransport: "ws-annexb", codec }),
501
+ onError: (error) => dispatch(this, "error", { error }),
502
+ onHealthy: () => this._markHealthy(generation),
503
+ onRecoverableError: (reason, details) => this._scheduleRecovery(generation, reason, details),
504
+ });
505
+ this._player.connect(response.wsUrl);
506
+ }
507
+
508
+ _attachVideoStream(stream, generation) {
509
+ const video = document.createElement("video");
510
+ video.muted = true;
511
+ video.autoplay = true;
512
+ video.playsInline = true;
513
+ video.srcObject = stream;
514
+ video.play().catch(() => {});
515
+ this._video = video;
516
+ const canvas = this.shadowRoot.querySelector("canvas");
517
+ const ctx = canvas.getContext("2d");
518
+ let lastFrameAt = 0;
519
+ let reportedHealthy = false;
520
+ const draw = () => {
521
+ if (!this._video) return;
522
+ if (video.videoWidth && video.videoHeight) {
523
+ if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
524
+ canvas.width = video.videoWidth;
525
+ canvas.height = video.videoHeight;
526
+ }
527
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
528
+ lastFrameAt = performance.now();
529
+ if (!reportedHealthy) {
530
+ reportedHealthy = true;
531
+ this._markHealthy(generation);
532
+ }
533
+ }
534
+ requestAnimationFrame(draw);
535
+ };
536
+ requestAnimationFrame(draw);
537
+ const watchdog = setInterval(() => {
538
+ if (this._video !== video) {
539
+ clearInterval(watchdog);
540
+ return;
541
+ }
542
+ if (lastFrameAt && performance.now() - lastFrameAt > 10000) {
543
+ clearInterval(watchdog);
544
+ this._scheduleRecovery(generation, "webrtc-video-stall", { msSinceFrame: Math.round(performance.now() - lastFrameAt) });
545
+ }
546
+ }, 2000);
547
+ }
548
+
549
+ _status(message, tone = "") {
550
+ const el = this.shadowRoot?.querySelector(".status");
551
+ if (!el) return;
552
+ el.textContent = message || "";
553
+ el.className = `status ${tone || ""}`;
554
+ }
555
+
556
+ _gatewayGuideHTML(title, body) {
557
+ return `
558
+ <div class="gateway-guide">
559
+ <strong>${escapeHTML(title)}</strong>
560
+ <span>${escapeHTML(body)}</span>
561
+ <a href="https://rtsp.flyfish.dev/#docs/web-component-gateway.md" target="_blank" rel="noreferrer">查看 Gateway 安装指引</a>
562
+ </div>
563
+ `;
564
+ }
565
+
566
+ _showGatewayGuide(title, body) {
567
+ const host = this.shadowRoot?.querySelector(".rtsp-host");
568
+ if (!host || host.querySelector(".gateway-guide")) return;
569
+ host.insertAdjacentHTML("beforeend", this._gatewayGuideHTML(title, body));
570
+ }
571
+
572
+ _stopMediaOnly() {
573
+ const streamId = this._streamId;
574
+ this._streamId = "";
575
+ this._streamToken = "";
576
+ if (this._player) {
577
+ this._player.close();
578
+ this._player = null;
579
+ }
580
+ if (this._pc) {
581
+ try { this._pc.close(); } catch {}
582
+ this._pc = null;
583
+ }
584
+ if (this._video) {
585
+ try { this._video.srcObject = null; } catch {}
586
+ this._video.remove();
587
+ this._video = null;
588
+ }
589
+ const bridge = desktopBridge();
590
+ if (streamId && bridge?.stopStream) {
591
+ bridge.stopStream({ streamId }).catch?.(() => {});
592
+ }
593
+ }
594
+
595
+ _clearRecoveryTimer() {
596
+ if (this._recoveryTimer) {
597
+ clearTimeout(this._recoveryTimer);
598
+ this._recoveryTimer = 0;
599
+ }
600
+ }
601
+
602
+ _markHealthy(generation) {
603
+ if (generation !== this._playGeneration || this._userStopped) return;
604
+ if (this._recoveryAttempts > 0) {
605
+ dispatch(this, "recovered", { attempt: this._recoveryAttempts });
606
+ }
607
+ this._recoveryAttempts = 0;
608
+ }
609
+
610
+ _scheduleRecovery(generation, reason, details = {}) {
611
+ if (generation !== this._playGeneration || this._userStopped || this._recoveryTimer) return;
612
+ if (this._recoveryAttempts >= MAX_RECOVERY_ATTEMPTS) {
613
+ const error = `Playback recovery failed: ${reason}`;
614
+ this._status(error, "error");
615
+ dispatch(this, "error", { error, reason, details });
616
+ return;
617
+ }
618
+ this._recoveryAttempts += 1;
619
+ const delayMs = Math.min(RECOVERY_MAX_DELAY_MS, RECOVERY_BASE_DELAY_MS * (2 ** Math.max(0, this._recoveryAttempts - 1)));
620
+ this._status(`Playback interrupted. Recovering (${this._recoveryAttempts}/${MAX_RECOVERY_ATTEMPTS})...`, "error");
621
+ dispatch(this, "recovering", { reason, details, attempt: this._recoveryAttempts, maxAttempts: MAX_RECOVERY_ATTEMPTS, delay: delayMs });
622
+ this._stopMediaOnly();
623
+ this._recoveryTimer = setTimeout(() => {
624
+ this._recoveryTimer = 0;
625
+ this._restartDesktopAfterRecovery(generation);
626
+ }, delayMs);
627
+ }
628
+
629
+ async _restartDesktopAfterRecovery(previousGeneration) {
630
+ if (this._userStopped || previousGeneration !== this._playGeneration) return;
631
+ const bridge = desktopBridge();
632
+ const url = this._url().trim();
633
+ if (!bridge?.startStream || !/^rtsps?:\/\//i.test(url)) return;
634
+ const generation = ++this._playGeneration;
635
+ const codec = this._codec();
636
+ await this._startWebSocket(bridge, url, codec, generation);
637
+ }
638
+ }
639
+
640
+ customElements.define(name, RTSPPlayerElement);
641
+
642
+ if (!window.__RTSP_PLAYER_MESSAGE_BRIDGE__) {
643
+ window.__RTSP_PLAYER_MESSAGE_BRIDGE__ = true;
644
+ window.addEventListener("message", (event) => {
645
+ const source = event.source;
646
+ const el = source ? frameOwners.get(source) : null;
647
+ if (!el || !event.data?.type?.startsWith?.("RTSP_PLAYER_")) return;
648
+ if (event.origin !== el._origin()) return;
649
+ const type = event.data.type.replace(/^RTSP_PLAYER_/, "").toLowerCase();
650
+ dispatch(el, type, event.data);
651
+ });
652
+ }
653
+
654
+ return RTSPPlayerElement;
655
+ }
656
+
657
+ export function createRTSPPlayer(options = {}) {
658
+ const tagName = options.tagName || globalConfig.tagName || DEFAULT_TAG_NAME;
659
+ defineRTSPPlayer(tagName, options);
660
+ const el = document.createElement(tagName);
661
+ updateRTSPPlayer(el, options);
662
+ return el;
663
+ }
664
+
665
+ export function updateRTSPPlayer(el, options = {}) {
666
+ const transport = options.transport;
667
+ const map = {
668
+ url: options.url,
669
+ src: options.src,
670
+ width: options.width,
671
+ height: options.height,
672
+ runtime: options.runtime,
673
+ codec: options.codec,
674
+ "extension-id": options.extensionId,
675
+ "media-transport": options.mediaTransport,
676
+ "rtsp-transport": options.rtspTransport,
677
+ };
678
+ if (transport !== undefined) {
679
+ if (transport === "tcp" || transport === "udp") map["rtsp-transport"] = transport;
680
+ else map.transport = transport;
681
+ }
682
+ for (const [key, value] of Object.entries(map)) {
683
+ if (value !== undefined && value !== null && value !== "") el.setAttribute(key, String(value));
684
+ }
685
+ setBooleanAttribute(el, "autoplay", options.autoplay);
686
+ setBooleanAttribute(el, "controls", options.controls);
687
+ setBooleanAttribute(el, "muted", options.muted);
688
+ return el;
689
+ }
690
+
691
+ function waitForIceGathering(pc) {
692
+ if (pc.iceGatheringState === "complete") return Promise.resolve();
693
+ return new Promise((resolve) => {
694
+ const timer = setTimeout(done, 1200);
695
+ function done() {
696
+ clearTimeout(timer);
697
+ pc.removeEventListener("icegatheringstatechange", onChange);
698
+ resolve();
699
+ }
700
+ function onChange() {
701
+ if (pc.iceGatheringState === "complete") done();
702
+ }
703
+ pc.addEventListener("icegatheringstatechange", onChange);
704
+ });
705
+ }
706
+
707
+ function delay(ms) {
708
+ return new Promise((resolve) => setTimeout(resolve, ms));
709
+ }
710
+
711
+ function escapeHTML(value) {
712
+ return String(value).replace(/[&<>"']/g, (ch) => ({
713
+ "&": "&amp;",
714
+ "<": "&lt;",
715
+ ">": "&gt;",
716
+ "\"": "&quot;",
717
+ "'": "&#39;",
718
+ }[ch]));
719
+ }
720
+
721
+ class AnnexBWebCodecsPlayer {
722
+ constructor(canvas, hooks = {}) {
723
+ this.canvas = canvas;
724
+ this.ctx = canvas.getContext("2d");
725
+ this.hooks = hooks;
726
+ this.ws = null;
727
+ this.decoder = null;
728
+ this.codec = "";
729
+ this.gotKey = false;
730
+ this.closed = false;
731
+ this.lastTimestamp = -1;
732
+ this.configuredAt = 0;
733
+ this.lastFrameAt = 0;
734
+ this.reportedHealthy = false;
735
+ this.decoderErrorTimes = [];
736
+ this.recovering = false;
737
+ this.watchdog = 0;
738
+ this.waitingKeyLogged = false;
739
+ this.waitingKeyStatusAt = 0;
740
+ }
741
+
742
+ connect(wsUrl) {
743
+ this.ws = new WebSocket(wsUrl);
744
+ this.ws.binaryType = "arraybuffer";
745
+ this.ws.onopen = () => this.hooks.onStatus?.("Connected to desktop gateway.");
746
+ this.ws.onerror = () => this.hooks.onStatus?.("WebSocket connection error.", "error");
747
+ this.ws.onclose = () => {
748
+ if (!this.closed) {
749
+ this.hooks.onStatus?.("Video connection closed.", "error");
750
+ this.notifyRecoverable("websocket-closed");
751
+ }
752
+ };
753
+ this.ws.onmessage = (event) => this.handleMessage(event.data);
754
+ this.startWatchdog();
755
+ }
756
+
757
+ async handleMessage(data) {
758
+ if (typeof data === "string") {
759
+ let msg;
760
+ try { msg = JSON.parse(data); } catch { return; }
761
+ if (msg.type === "config") await this.configure(msg.codec);
762
+ else if (msg.type === "error") this.notifyRecoverable("gateway-error", { error: msg.error || "RTSP error" });
763
+ return;
764
+ }
765
+ if (data instanceof ArrayBuffer) this.handleAccessUnit(data);
766
+ }
767
+
768
+ async configure(codec) {
769
+ codec ||= "avc1.42E01E";
770
+ if (this.decoder && this.codec === codec) return;
771
+ this.codec = codec;
772
+ if (this.decoder) {
773
+ try { this.decoder.close(); } catch {}
774
+ }
775
+ if (!("VideoDecoder" in window)) {
776
+ this.hooks.onStatus?.("VideoDecoder is not supported in this runtime.", "error");
777
+ this.hooks.onError?.("VideoDecoder is not supported in this runtime.");
778
+ return;
779
+ }
780
+ const config = { codec, hardwareAcceleration: "prefer-hardware", optimizeForLatency: true };
781
+ try {
782
+ const support = await VideoDecoder.isConfigSupported(config);
783
+ if (!support.supported) {
784
+ const msg = `Runtime does not support codec ${codec}.`;
785
+ this.hooks.onStatus?.(msg, "error");
786
+ this.hooks.onError?.(msg);
787
+ return;
788
+ }
789
+ } catch {}
790
+ this.decoder = new VideoDecoder({
791
+ output: (frame) => this.render(frame),
792
+ error: (err) => this.recoverDecoder(err?.message || String(err)),
793
+ });
794
+ this.decoder.configure(config);
795
+ this.gotKey = false;
796
+ this.configuredAt = performance.now();
797
+ this.waitingKeyLogged = false;
798
+ this.waitingKeyStatusAt = 0;
799
+ this.hooks.onStatus?.(`Decoder ready: ${codec}. Waiting for the first key/startup frame...`, "ok");
800
+ }
801
+
802
+ handleAccessUnit(buffer) {
803
+ if (buffer.byteLength < 16 || !this.decoder || this.decoder.state !== "configured") return;
804
+ const view = new DataView(buffer);
805
+ if (view.getUint8(0) !== 1) return;
806
+ const key = view.getUint8(1) === 1;
807
+ let timestamp = Number(view.getBigUint64(4, true));
808
+ const length = view.getUint32(12, true);
809
+ if (length <= 0 || 16 + length > buffer.byteLength) return;
810
+ if (!key && !this.gotKey) {
811
+ const now = performance.now();
812
+ if (!this.waitingKeyLogged || now - this.waitingKeyStatusAt > 5000) {
813
+ this.waitingKeyLogged = true;
814
+ this.waitingKeyStatusAt = now;
815
+ this.hooks.onStatus?.("Waiting for the first camera key/startup frame...");
816
+ }
817
+ return;
818
+ }
819
+ if (key && !this.gotKey) {
820
+ this.gotKey = true;
821
+ this.hooks.onStatus?.("Keyframe received. Rendering first frame...");
822
+ }
823
+ if (timestamp <= this.lastTimestamp) timestamp = this.lastTimestamp + 1;
824
+ this.lastTimestamp = timestamp;
825
+ if (!key && this.decoder.decodeQueueSize > 6) return;
826
+ try {
827
+ this.decoder.decode(new EncodedVideoChunk({
828
+ type: key ? "key" : "delta",
829
+ timestamp,
830
+ data: new Uint8Array(buffer, 16, length),
831
+ }));
832
+ } catch (err) {
833
+ this.recoverDecoder(err?.message || String(err));
834
+ }
835
+ }
836
+
837
+ render(frame) {
838
+ try {
839
+ if (this.canvas.width !== frame.displayWidth || this.canvas.height !== frame.displayHeight) {
840
+ this.canvas.width = frame.displayWidth;
841
+ this.canvas.height = frame.displayHeight;
842
+ }
843
+ this.ctx.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height);
844
+ this.lastFrameAt = performance.now();
845
+ if (!this.reportedHealthy) {
846
+ this.reportedHealthy = true;
847
+ this.hooks.onStatus?.(`Video ready: ${frame.displayWidth}x${frame.displayHeight}`, "ok");
848
+ this.hooks.onReady?.();
849
+ this.hooks.onHealthy?.();
850
+ }
851
+ } finally {
852
+ frame.close();
853
+ }
854
+ }
855
+
856
+ recoverDecoder(error) {
857
+ if (this.closed || this.recovering) return;
858
+ const now = performance.now();
859
+ this.decoderErrorTimes = this.decoderErrorTimes.filter((ts) => now - ts < 10000);
860
+ this.decoderErrorTimes.push(now);
861
+ if (this.decoderErrorTimes.length > 2) {
862
+ this.notifyRecoverable("decoder-error-loop", { error, codec: this.codec, decoderErrors: this.decoderErrorTimes.length });
863
+ return;
864
+ }
865
+ this.recovering = true;
866
+ try {
867
+ if (this.decoder) this.decoder.close();
868
+ } catch {}
869
+ this.decoder = null;
870
+ this.gotKey = false;
871
+ this.hooks.onStatus?.("Decoder error. Rebuilding decoder...", "error");
872
+ setTimeout(() => {
873
+ if (this.closed) return;
874
+ this.recovering = false;
875
+ this.configure(this.codec).catch((err) => {
876
+ this.notifyRecoverable("decoder-reconfigure-failed", { error: err?.message || String(err), codec: this.codec });
877
+ });
878
+ }, 180);
879
+ }
880
+
881
+ startWatchdog() {
882
+ if (this.watchdog) return;
883
+ this.watchdog = setInterval(() => {
884
+ if (this.closed || this.recovering) return;
885
+ const now = performance.now();
886
+ if (this.configuredAt && !this.lastFrameAt && now - this.configuredAt > WEBSOCKET_KEYFRAME_NOTICE_MS && !this.waitingKeyLogged) {
887
+ this.waitingKeyLogged = true;
888
+ this.waitingKeyStatusAt = now;
889
+ this.hooks.onStatus?.("Waiting for the first camera key/startup frame...");
890
+ }
891
+ if (this.configuredAt && !this.lastFrameAt && now - this.configuredAt > WEBSOCKET_FIRST_FRAME_TIMEOUT_MS) {
892
+ this.notifyRecoverable("video-stall-before-first-frame", { codec: this.codec });
893
+ return;
894
+ }
895
+ if (this.lastFrameAt && now - this.lastFrameAt > 10000) {
896
+ this.notifyRecoverable("video-stall", { codec: this.codec, msSinceFrame: Math.round(now - this.lastFrameAt) });
897
+ }
898
+ }, 2000);
899
+ }
900
+
901
+ notifyRecoverable(reason, details = {}) {
902
+ if (this.closed || this.recovering) return;
903
+ this.recovering = true;
904
+ this.hooks.onRecoverableError?.(reason, details);
905
+ }
906
+
907
+ close() {
908
+ this.closed = true;
909
+ if (this.watchdog) {
910
+ clearInterval(this.watchdog);
911
+ this.watchdog = 0;
912
+ }
913
+ if (this.ws) {
914
+ try { this.ws.close(); } catch {}
915
+ this.ws = null;
916
+ }
917
+ if (this.decoder) {
918
+ try { this.decoder.close(); } catch {}
919
+ this.decoder = null;
920
+ }
921
+ }
922
+ }