beauticode-dsh 1.0.20 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +1367 -1214
- package/index.mjs +57 -43
- package/package.json +1 -1
- package/vendor/core/apply-transaction.js +24 -2
- package/vendor/core/background-store.js +5 -1
- package/vendor/core/media-validation.js +33 -0
package/client.js
CHANGED
|
@@ -4,34 +4,45 @@
|
|
|
4
4
|
window.__beauticodeBridgeLoaded = true;
|
|
5
5
|
window.__beauticodeBridgeVersion = 4;
|
|
6
6
|
|
|
7
|
-
const clientId =
|
|
8
|
-
globalThis.crypto?.randomUUID?.() ||
|
|
9
|
-
`bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
10
|
-
const desiredModes = { fish: false, muted: true, tone: "auto" };
|
|
11
|
-
// The DSH host owns a hard 10-second verification boundary. Keep the browser
|
|
12
|
-
// transaction inside it so failures remain phase-specific instead of turning
|
|
13
|
-
// into a generic host timeout.
|
|
14
|
-
const CLIENT_APPLY_DEADLINE_MS = 8_000;
|
|
15
|
-
const IMAGE_LOAD_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
16
|
-
const IMAGE_ATTEMPT_TIMEOUT_MS = 3_000;
|
|
17
|
-
const IMAGE_MAX_ATTEMPTS = 2;
|
|
18
|
-
const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
19
|
-
const VIDEO_PROBE_TIMEOUT_MS = 2_000;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
7
|
+
const clientId =
|
|
8
|
+
globalThis.crypto?.randomUUID?.() ||
|
|
9
|
+
`bc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
10
|
+
const desiredModes = { fish: false, muted: true, tone: "auto" };
|
|
11
|
+
// The DSH host owns a hard 10-second verification boundary. Keep the browser
|
|
12
|
+
// transaction inside it so failures remain phase-specific instead of turning
|
|
13
|
+
// into a generic host timeout.
|
|
14
|
+
const CLIENT_APPLY_DEADLINE_MS = 8_000;
|
|
15
|
+
const IMAGE_LOAD_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
16
|
+
const IMAGE_ATTEMPT_TIMEOUT_MS = 3_000;
|
|
17
|
+
const IMAGE_MAX_ATTEMPTS = 2;
|
|
18
|
+
const VIDEO_STARTUP_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
19
|
+
const VIDEO_PROBE_TIMEOUT_MS = 2_000;
|
|
20
|
+
// Phase-one playback acceptance window. A muted play() that is still pending
|
|
21
|
+
// after this only means the decoder is warming; the settle phase owns it.
|
|
22
|
+
const PLAY_ACCEPT_TIMEOUT_MS = 1_500;
|
|
23
|
+
// Phase-two budget for the committed video to present its first frame. It is
|
|
24
|
+
// deliberately far beyond any host deadline: a poster already committed, so
|
|
25
|
+
// slow first frames upgrade in place instead of failing the transaction.
|
|
26
|
+
const VIDEO_SETTLE_TIMEOUT_MS = 60_000;
|
|
27
|
+
const DSH_STRUCTURE_TIMEOUT_MS = CLIENT_APPLY_DEADLINE_MS;
|
|
28
|
+
const FRAME_FALLBACK_MS = 120;
|
|
29
|
+
const VIDEO_FIRST_FRAME_PROGRESS_SEC = 0.03;
|
|
30
|
+
const VIDEO_STABLE_FRAMES = 3;
|
|
31
|
+
const VIDEO_STABLE_PROGRESS_SEC = 0.18;
|
|
32
|
+
const CROSSFADE_MS = 180;
|
|
33
|
+
// A 1486-byte H.264 black frame (faststart). Playing it once on init makes
|
|
34
|
+
// Chromium build its media pipeline before the user's first real import.
|
|
35
|
+
const WARMUP_VIDEO_DATA_URI = `data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMQbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAH0AAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAjp0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAH0AAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAB9AAAAAAABAAAAAAGybWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAACABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABXW1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAR1zdGJsAAAAuXN0c2QAAAAAAAAAAQAAAKlhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAAL2F2Y0MBQsAN/+EAF2dCwA3ZBCbARAAAAwAEAAADAEA8UKkgAQAFaMuDyyAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAACjgAAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAQAACAAAAAAcc3RzYwAAAAAAAAABAAAAAQAAAAEAAAABAAAAFHN0c3oAAAAAAAACjgAAAAEAAAAUc3RjbwAAAAAAAAABAAADQAAAAGJ1ZHRhAAAAWm1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALWlsc3QAAAAlqXRvbwAAAB1kYXRhAAAAAQAAAABMYXZmNjIuMTIuMTAyAAAACGZyZWUAAAKWbWRhdAAAAnAGBf//bNxF6b3m2Ui3lizYINkj7u94MjY0IC0gY29yZSAxNjUgcjMyMjMgMDQ4MGNiMCAtIEguMjY0L01QRUctNCBBVkMgY29kZWMgLSBDb3B5bGVmdCAyMDAzLTIwMjUgLSBodHRwOi8vd3d3LnZpZGVvbGFuLm9yZy94MjY0Lmh0bWwgLSBvcHRpb25zOiBjYWJhYz0wIHJlZj0zIGRlYmxvY2s9MTowOjAgYW5hbHl6ZT0weDE6MHgxMTEgbWU9aGV4IHN1Ym1lPTcgcHN5PTEgcHN5X3JkPTEuMDA6MC4wMCBtaXhlZF9yZWY9MSBtZV9yYW5nZT0xNiBjaHJvbWFfbWU9MSB0cmVsbGlzPTEgOHg4ZGN0PTAgY3FtPTAgZGVhZHpvbmU9MjEsMTEgZmFzdF9wc2tpcD0xIGNocm9tYV9xcF9vZmZzZXQ9LTIgdGhyZWFkcz0yIGxvb2thaGVhZF90aHJlYWRzPTEgc2xpY2VkX3RocmVhZHM9MCBucj0wIGRlY2ltYXRlPTEgaW50ZXJsYWNlPTAgYmx1cmF5X2NvbXBhdD0wIGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0wIHdlaWdodHA9MCBrZXlpbnQ9MjUwIGtleWludF9taW49OCBzY2VuZWN1dD00MCBpbnRyYV9yZWZyZXNoPTAgcmNfbG9va2FoZWFkPTQwIHJjPWNyZiBtYnRyZWU9MSBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0xOjEuMDBAIAAAAAWZYiEDvJigAC+/JycnXXXXXXXXXXXXg==`;
|
|
36
|
+
let activePayload = null;
|
|
37
|
+
let committedPayload = null;
|
|
38
|
+
let renderPhase = "idle";
|
|
39
|
+
let playbackBlocked = false;
|
|
40
|
+
let currentSlot = null;
|
|
41
|
+
let applyController = null;
|
|
42
|
+
let videoSettleController = null;
|
|
43
|
+
const systemDarkMedia = globalThis.matchMedia?.("(prefers-color-scheme: dark)") ?? null;
|
|
44
|
+
const reducedMotionMedia =
|
|
45
|
+
globalThis.matchMedia?.("(prefers-reduced-motion: reduce)") ?? null;
|
|
35
46
|
let themeSyncQueued = false;
|
|
36
47
|
|
|
37
48
|
const style = document.createElement("style");
|
|
@@ -69,34 +80,85 @@ html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase
|
|
|
69
80
|
--dsw-specific-sidebar-fill:rgba(255,255,255,.78);
|
|
70
81
|
}
|
|
71
82
|
#beauticode-bg-stage{position:fixed;inset:0;z-index:0;overflow:hidden;pointer-events:none;background:#11141b}
|
|
72
|
-
#beauticode-bg-stage::after{content:"";position:absolute;inset:0;z-index:3;background:transparent;pointer-events:none}
|
|
83
|
+
#beauticode-bg-stage::after{content:"";position:absolute;inset:0;z-index:3;background:transparent;pointer-events:none}
|
|
73
84
|
html[data-bc-resolved-tone="light"] #beauticode-bg-stage{background:#f8fafc}
|
|
74
85
|
html[data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
|
|
75
86
|
html[data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(0,0,0,.42)}
|
|
76
87
|
html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="active"]) #beauticode-bg-stage::after,
|
|
77
88
|
html[data-bc-resolved-tone="light"][data-bc-active="true"]:has(#root [data-phase="settling"]) #beauticode-bg-stage::after{background:rgba(255,255,255,.22)}
|
|
78
89
|
html[data-bc-fish="true"] #beauticode-bg-stage::after{background:transparent!important}
|
|
79
|
-
#beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:1;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity}
|
|
80
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
|
|
81
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
|
|
82
|
-
#beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
|
|
83
|
-
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
|
|
84
|
-
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
|
|
85
|
-
#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease}
|
|
86
|
-
#beauticode-bg-stage .beauticode-media-slot img{z-index:2;opacity:1}
|
|
87
|
-
/* Keep cold candidate video paintable. Chromium may defer decoding media that
|
|
88
|
-
is nearly transparent, which deadlocks the first-frame gate. The poster
|
|
89
|
-
covers it until data-bc-video-ready is committed. */
|
|
90
|
-
#beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:1}
|
|
91
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0}
|
|
92
|
-
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1}
|
|
93
|
-
@media (prefers-reduced-motion:reduce){#beauticode-bg-stage .beauticode-media-slot,#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{transition:none!important}}
|
|
90
|
+
#beauticode-bg-stage .beauticode-media-slot{position:absolute;inset:0;z-index:0;opacity:1;overflow:hidden;pointer-events:none;transition:opacity ${CROSSFADE_MS}ms ease;will-change:opacity}
|
|
91
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="current"]{z-index:1;opacity:1}
|
|
92
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-role="candidate"]{z-index:2;opacity:1}
|
|
93
|
+
#beauticode-bg-stage[data-bc-empty="true"] .beauticode-media-slot[data-bc-role="candidate"]{z-index:1}
|
|
94
|
+
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="current"]{opacity:0}
|
|
95
|
+
#beauticode-bg-stage[data-bc-transitioning="true"] .beauticode-media-slot[data-bc-role="candidate"]{opacity:1}
|
|
96
|
+
#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;transition:opacity 120ms ease}
|
|
97
|
+
#beauticode-bg-stage .beauticode-media-slot img{z-index:2;opacity:1}
|
|
98
|
+
/* Keep cold candidate video paintable. Chromium may defer decoding media that
|
|
99
|
+
is nearly transparent, which deadlocks the first-frame gate. The poster
|
|
100
|
+
covers it until data-bc-video-ready is committed. */
|
|
101
|
+
#beauticode-bg-stage .beauticode-media-slot video{z-index:1;opacity:1}
|
|
102
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] img{opacity:0}
|
|
103
|
+
#beauticode-bg-stage .beauticode-media-slot[data-bc-video-ready="true"] video{opacity:1}
|
|
104
|
+
@media (prefers-reduced-motion:reduce){#beauticode-bg-stage .beauticode-media-slot,#beauticode-bg-stage .beauticode-media-slot img,#beauticode-bg-stage .beauticode-media-slot video{transition:none!important}}
|
|
94
105
|
html[data-bc-active="true"] #root{position:relative;z-index:1;background:transparent!important}
|
|
95
106
|
html[data-bc-active="true"] [class*="_fade"]{display:none!important}
|
|
96
107
|
html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;pointer-events:none!important}
|
|
97
108
|
`;
|
|
98
109
|
document.head.append(style);
|
|
99
110
|
|
|
111
|
+
// Chromium builds its media stack lazily; the first <video> of a fresh
|
|
112
|
+
// profile pays decoder/GPU/audio init inside the first import's verify
|
|
113
|
+
// deadline. One muted playback of the bundled frame pays that cost early.
|
|
114
|
+
// Everything is best-effort: a warmup failure must never affect the page.
|
|
115
|
+
function warmUpMediaStack() {
|
|
116
|
+
try {
|
|
117
|
+
const mount = document.documentElement;
|
|
118
|
+
if (typeof mount?.append !== "function") return;
|
|
119
|
+
if (mount.dataset.bcMediaWarmup === "done") return;
|
|
120
|
+
mount.dataset.bcMediaWarmup = "running";
|
|
121
|
+
const video = document.createElement("video");
|
|
122
|
+
let cleaned = false;
|
|
123
|
+
const cleanup = () => {
|
|
124
|
+
if (cleaned) return;
|
|
125
|
+
cleaned = true;
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
try {
|
|
128
|
+
video.removeAttribute("src");
|
|
129
|
+
video.load();
|
|
130
|
+
} catch {}
|
|
131
|
+
video.remove?.();
|
|
132
|
+
mount.dataset.bcMediaWarmup = "done";
|
|
133
|
+
};
|
|
134
|
+
const timer = setTimeout(cleanup, 15_000);
|
|
135
|
+
video.muted = true;
|
|
136
|
+
video.defaultMuted = true;
|
|
137
|
+
video.playsInline = true;
|
|
138
|
+
video.autoplay = true;
|
|
139
|
+
video.setAttribute?.("muted", "");
|
|
140
|
+
video.setAttribute?.("disablepictureinpicture", "");
|
|
141
|
+
video.setAttribute?.("aria-hidden", "true");
|
|
142
|
+
video.tabIndex = -1;
|
|
143
|
+
// Nearly transparent or hidden media can be decode-deferred (see the
|
|
144
|
+
// first-frame fix); a 4x4 fully painted square under every layer is
|
|
145
|
+
// imperceptible while remaining decodable.
|
|
146
|
+
video.style.cssText =
|
|
147
|
+
"position:fixed;left:0;top:0;width:4px;height:4px;opacity:1;pointer-events:none;z-index:-2147483647;border:0;margin:0;padding:0";
|
|
148
|
+
video.addEventListener?.("ended", cleanup, { once: true });
|
|
149
|
+
video.addEventListener?.("error", cleanup, { once: true });
|
|
150
|
+
video.src = WARMUP_VIDEO_DATA_URI;
|
|
151
|
+
mount.append(video);
|
|
152
|
+
const playback = video.play?.();
|
|
153
|
+
playback?.catch?.(() => {});
|
|
154
|
+
} catch {
|
|
155
|
+
try {
|
|
156
|
+
document.documentElement.dataset.bcMediaWarmup = "done";
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
warmUpMediaStack();
|
|
161
|
+
|
|
100
162
|
function dshAppearance() {
|
|
101
163
|
const body = document.body;
|
|
102
164
|
if (body?.hasAttribute("data-ds-dark-theme")) return "dark";
|
|
@@ -139,12 +201,12 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
139
201
|
attributeFilter: ["data-ds-dark-theme"],
|
|
140
202
|
});
|
|
141
203
|
}
|
|
142
|
-
systemDarkMedia?.addEventListener("change", () => {
|
|
143
|
-
if (desiredModes.tone === "auto") {
|
|
144
|
-
syncDshTheme();
|
|
145
|
-
void acknowledgeMode().catch(() => {});
|
|
146
|
-
}
|
|
147
|
-
});
|
|
204
|
+
systemDarkMedia?.addEventListener("change", () => {
|
|
205
|
+
if (desiredModes.tone === "auto") {
|
|
206
|
+
syncDshTheme();
|
|
207
|
+
void acknowledgeMode().catch(() => {});
|
|
208
|
+
}
|
|
209
|
+
});
|
|
148
210
|
syncDshTheme();
|
|
149
211
|
|
|
150
212
|
function stage() {
|
|
@@ -162,67 +224,67 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
162
224
|
* If the shell no longer exposes it, report a clear error through the
|
|
163
225
|
* render ack instead of silently painting a broken page.
|
|
164
226
|
*/
|
|
165
|
-
function dshStructureIssue() {
|
|
166
|
-
if (!document.getElementById("root")) {
|
|
167
|
-
return "DSH 页面结构不兼容:未找到 #root。";
|
|
168
|
-
}
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) {
|
|
173
|
-
if (!dshStructureIssue()) return Promise.resolve();
|
|
174
|
-
return new Promise((resolve, reject) => {
|
|
175
|
-
let settled = false;
|
|
176
|
-
const startedAt = performance.now();
|
|
177
|
-
const finish = (error = null) => {
|
|
178
|
-
if (settled) return;
|
|
179
|
-
settled = true;
|
|
180
|
-
clearInterval(timer);
|
|
181
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
182
|
-
if (error) reject(error);
|
|
183
|
-
else resolve();
|
|
184
|
-
};
|
|
185
|
-
const aborted = () => finish(abortError());
|
|
186
|
-
const timer = setInterval(() => {
|
|
187
|
-
const issue = dshStructureIssue();
|
|
188
|
-
if (!issue) {
|
|
189
|
-
finish();
|
|
190
|
-
} else if (performance.now() - startedAt >= timeoutMs) {
|
|
191
|
-
finish(new Error(issue));
|
|
192
|
-
}
|
|
193
|
-
}, 50);
|
|
194
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
195
|
-
if (signal?.aborted) aborted();
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function mountedCurrentSlot() {
|
|
200
|
-
const node = document.getElementById("beauticode-bg-stage");
|
|
201
|
-
if (
|
|
202
|
-
!currentSlot ||
|
|
203
|
-
currentSlot.isConnected !== true ||
|
|
204
|
-
currentSlot.parentElement !== node ||
|
|
205
|
-
currentSlot.dataset.bcRole !== "current"
|
|
206
|
-
) {
|
|
207
|
-
currentSlot = null;
|
|
208
|
-
committedPayload = null;
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
211
|
-
return currentSlot;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function activeVideo() {
|
|
215
|
-
const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
|
|
216
|
-
return video instanceof HTMLVideoElement ? video : null;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function pendingVideo() {
|
|
220
|
-
const node = document.getElementById("beauticode-bg-stage");
|
|
221
|
-
const video = node?.querySelector?.(
|
|
222
|
-
'.beauticode-media-slot[data-bc-role="candidate"] video',
|
|
223
|
-
);
|
|
224
|
-
return video instanceof HTMLVideoElement ? video : null;
|
|
225
|
-
}
|
|
227
|
+
function dshStructureIssue() {
|
|
228
|
+
if (!document.getElementById("root")) {
|
|
229
|
+
return "DSH 页面结构不兼容:未找到 #root。";
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function waitForDshStructure(signal, timeoutMs = DSH_STRUCTURE_TIMEOUT_MS) {
|
|
235
|
+
if (!dshStructureIssue()) return Promise.resolve();
|
|
236
|
+
return new Promise((resolve, reject) => {
|
|
237
|
+
let settled = false;
|
|
238
|
+
const startedAt = performance.now();
|
|
239
|
+
const finish = (error = null) => {
|
|
240
|
+
if (settled) return;
|
|
241
|
+
settled = true;
|
|
242
|
+
clearInterval(timer);
|
|
243
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
244
|
+
if (error) reject(error);
|
|
245
|
+
else resolve();
|
|
246
|
+
};
|
|
247
|
+
const aborted = () => finish(abortError());
|
|
248
|
+
const timer = setInterval(() => {
|
|
249
|
+
const issue = dshStructureIssue();
|
|
250
|
+
if (!issue) {
|
|
251
|
+
finish();
|
|
252
|
+
} else if (performance.now() - startedAt >= timeoutMs) {
|
|
253
|
+
finish(new Error(issue));
|
|
254
|
+
}
|
|
255
|
+
}, 50);
|
|
256
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
257
|
+
if (signal?.aborted) aborted();
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function mountedCurrentSlot() {
|
|
262
|
+
const node = document.getElementById("beauticode-bg-stage");
|
|
263
|
+
if (
|
|
264
|
+
!currentSlot ||
|
|
265
|
+
currentSlot.isConnected !== true ||
|
|
266
|
+
currentSlot.parentElement !== node ||
|
|
267
|
+
currentSlot.dataset.bcRole !== "current"
|
|
268
|
+
) {
|
|
269
|
+
currentSlot = null;
|
|
270
|
+
committedPayload = null;
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
return currentSlot;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function activeVideo() {
|
|
277
|
+
const video = mountedCurrentSlot()?.querySelector?.("video") ?? null;
|
|
278
|
+
return video instanceof HTMLVideoElement ? video : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function pendingVideo() {
|
|
282
|
+
const node = document.getElementById("beauticode-bg-stage");
|
|
283
|
+
const video = node?.querySelector?.(
|
|
284
|
+
'.beauticode-media-slot[data-bc-role="candidate"] video',
|
|
285
|
+
);
|
|
286
|
+
return video instanceof HTMLVideoElement ? video : null;
|
|
287
|
+
}
|
|
226
288
|
|
|
227
289
|
async function postAck(body) {
|
|
228
290
|
await fetch("/__beauticode/ack", {
|
|
@@ -244,10 +306,14 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
244
306
|
};
|
|
245
307
|
}
|
|
246
308
|
|
|
247
|
-
async function acknowledgeRender(payload, ok, visible, error = null) {
|
|
309
|
+
async function acknowledgeRender(payload, ok, visible, error = null, extra = {}) {
|
|
248
310
|
if (activePayload === payload) {
|
|
249
311
|
renderPhase = ok ? "ready" : "failed";
|
|
250
312
|
}
|
|
313
|
+
// A committed video apply may still be settling: ok=true with
|
|
314
|
+
// videoReady=false means the poster is live and the first frame upgrades
|
|
315
|
+
// in place. Only ok=false is a renderer verdict the host will act on.
|
|
316
|
+
const videoReady = extra.videoReady !== false;
|
|
251
317
|
await postAck({
|
|
252
318
|
kind: "render",
|
|
253
319
|
generation: payload.generation,
|
|
@@ -255,10 +321,11 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
255
321
|
ok,
|
|
256
322
|
visible,
|
|
257
323
|
error,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
324
|
+
...(payload.media === "video" ? { videoReady } : {}),
|
|
325
|
+
playback:
|
|
326
|
+
ok && committedPayload === payload && payload.media === "video" && videoReady
|
|
327
|
+
? playbackSnapshot(activeVideo())
|
|
328
|
+
: null,
|
|
262
329
|
});
|
|
263
330
|
}
|
|
264
331
|
|
|
@@ -276,290 +343,333 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
276
343
|
});
|
|
277
344
|
}
|
|
278
345
|
|
|
279
|
-
function abortError() {
|
|
280
|
-
try {
|
|
281
|
-
return new DOMException("Background apply superseded", "AbortError");
|
|
282
|
-
} catch {
|
|
283
|
-
const error = new Error("Background apply superseded");
|
|
284
|
-
error.name = "AbortError";
|
|
285
|
-
return error;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function isAbortError(error) {
|
|
290
|
-
return error?.name === "AbortError";
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function throwIfAborted(signal) {
|
|
294
|
-
if (signal?.aborted) throw abortError();
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function releaseImage(image, { remove = false } = {}) {
|
|
298
|
-
if (!image) return;
|
|
299
|
-
image.onload = null;
|
|
300
|
-
image.onerror = null;
|
|
301
|
-
try {
|
|
302
|
-
image.removeAttribute?.("src");
|
|
303
|
-
} catch {
|
|
304
|
-
image.src = "";
|
|
305
|
-
}
|
|
306
|
-
if (remove) image.remove?.();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function imageAttemptUrl(url, attempt) {
|
|
310
|
-
if (attempt === 0) return url;
|
|
311
|
-
try {
|
|
312
|
-
const retryUrl = new URL(url, globalThis.location?.href);
|
|
313
|
-
retryUrl.searchParams.set(
|
|
314
|
-
"bcImageRetry",
|
|
315
|
-
`${clientId}-${attempt}-${Date.now().toString(36)}`,
|
|
316
|
-
);
|
|
317
|
-
return retryUrl.href;
|
|
318
|
-
} catch {
|
|
319
|
-
return url;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function waitForImageAttempt(image, url, signal, timeoutMs) {
|
|
324
|
-
return new Promise((resolve, reject) => {
|
|
325
|
-
let settled = false;
|
|
326
|
-
let decodeStarted = false;
|
|
327
|
-
let decodeFallback = null;
|
|
328
|
-
const cleanup = () => {
|
|
329
|
-
clearTimeout(timer);
|
|
330
|
-
if (decodeFallback) clearTimeout(decodeFallback);
|
|
331
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
332
|
-
image.onload = null;
|
|
333
|
-
image.onerror = null;
|
|
334
|
-
};
|
|
335
|
-
const finish = (error = null) => {
|
|
336
|
-
if (settled) return;
|
|
337
|
-
settled = true;
|
|
338
|
-
cleanup();
|
|
339
|
-
if (error) reject(error);
|
|
340
|
-
else resolve();
|
|
341
|
-
};
|
|
342
|
-
const acceptLoaded = () => {
|
|
343
|
-
if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
|
|
344
|
-
if (image.complete) finish(new Error("图片尺寸无效"));
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (!decodeStarted && typeof image.decode === "function") {
|
|
348
|
-
decodeStarted = true;
|
|
349
|
-
// decode() is a second readiness signal, but the per-attempt timer
|
|
350
|
-
// remains authoritative. A loaded image is still accepted after a
|
|
351
|
-
// short decode grace period if Chromium leaves decode() pending.
|
|
352
|
-
decodeFallback = setTimeout(() => {
|
|
353
|
-
if (
|
|
354
|
-
image.isConnected === true &&
|
|
355
|
-
image.complete &&
|
|
356
|
-
image.naturalWidth > 0 &&
|
|
357
|
-
image.naturalHeight > 0
|
|
358
|
-
) {
|
|
359
|
-
finish();
|
|
360
|
-
} else {
|
|
361
|
-
finish(new Error("图片解码未完成"));
|
|
362
|
-
}
|
|
363
|
-
}, 750);
|
|
364
|
-
let decodePromise;
|
|
365
|
-
try {
|
|
366
|
-
decodePromise = image.decode();
|
|
367
|
-
} catch {
|
|
368
|
-
finish(new Error("图片解码失败"));
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
Promise.resolve(decodePromise).then(
|
|
372
|
-
() => finish(),
|
|
373
|
-
() => finish(new Error("图片解码失败")),
|
|
374
|
-
);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
finish();
|
|
378
|
-
};
|
|
379
|
-
const failed = () => finish(new Error("图片请求失败"));
|
|
380
|
-
const aborted = () => finish(abortError());
|
|
381
|
-
const timer = setTimeout(
|
|
382
|
-
() => finish(new Error("等待图片请求或解码超时")),
|
|
383
|
-
timeoutMs,
|
|
384
|
-
);
|
|
385
|
-
image.onload = acceptLoaded;
|
|
386
|
-
image.onerror = failed;
|
|
387
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
388
|
-
if (signal?.aborted) {
|
|
389
|
-
aborted();
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
image.src = url;
|
|
393
|
-
if (image.complete) queueMicrotask(acceptLoaded);
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function waitForRetryWindow(signal, timeoutMs) {
|
|
398
|
-
return new Promise((resolve, reject) => {
|
|
399
|
-
let settled = false;
|
|
400
|
-
const finish = (error = null) => {
|
|
401
|
-
if (settled) return;
|
|
402
|
-
settled = true;
|
|
403
|
-
clearTimeout(timer);
|
|
404
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
405
|
-
if (error) reject(error);
|
|
406
|
-
else resolve();
|
|
407
|
-
};
|
|
408
|
-
const aborted = () => finish(abortError());
|
|
409
|
-
const timer = setTimeout(() => finish(), timeoutMs);
|
|
410
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
411
|
-
if (signal?.aborted) aborted();
|
|
412
|
-
});
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
|
|
416
|
-
const deadline = performance.now() + timeoutMs;
|
|
417
|
-
const attempts = [];
|
|
418
|
-
let lastError = null;
|
|
419
|
-
|
|
420
|
-
const startAttempt = (attempt) => {
|
|
421
|
-
throwIfAborted(signal);
|
|
422
|
-
const remainingMs = deadline - performance.now();
|
|
423
|
-
if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
|
|
424
|
-
const controller = new AbortController();
|
|
425
|
-
const parentAborted = () => controller.abort();
|
|
426
|
-
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
427
|
-
const image = new Image();
|
|
428
|
-
image.alt = "";
|
|
429
|
-
image.decoding = "async";
|
|
430
|
-
image.className = "beauticode-media-poster";
|
|
431
|
-
slot.prepend(image);
|
|
432
|
-
const promise = waitForImageAttempt(
|
|
433
|
-
image,
|
|
434
|
-
imageAttemptUrl(url, attempt),
|
|
435
|
-
controller.signal,
|
|
436
|
-
Math.max(1, remainingMs),
|
|
437
|
-
)
|
|
438
|
-
.then(() => image)
|
|
439
|
-
.finally(() => signal?.removeEventListener?.("abort", parentAborted));
|
|
440
|
-
const record = { controller, image, promise };
|
|
441
|
-
attempts.push(record);
|
|
442
|
-
return record;
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
const cleanupAttempts = (winner = null) => {
|
|
446
|
-
for (const attempt of attempts) {
|
|
447
|
-
if (attempt.image === winner) continue;
|
|
448
|
-
attempt.controller.abort();
|
|
449
|
-
releaseImage(attempt.image, { remove: true });
|
|
450
|
-
}
|
|
451
|
-
};
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
const first = startAttempt(0);
|
|
455
|
-
const firstWindow = await Promise.race([
|
|
456
|
-
first.promise.then(
|
|
457
|
-
(image) => ({ image }),
|
|
458
|
-
(error) => ({ error }),
|
|
459
|
-
),
|
|
460
|
-
waitForRetryWindow(
|
|
461
|
-
signal,
|
|
462
|
-
Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
|
|
463
|
-
).then(() => ({ retry: true })),
|
|
464
|
-
]);
|
|
465
|
-
if (firstWindow.image) {
|
|
466
|
-
cleanupAttempts(firstWindow.image);
|
|
467
|
-
return firstWindow.image;
|
|
468
|
-
}
|
|
469
|
-
if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
|
|
470
|
-
lastError = firstWindow.error ?? lastError;
|
|
471
|
-
|
|
472
|
-
// Keep the original request alive. The cache-busted retry runs beside it
|
|
473
|
-
// so a merely slow cold decode is never destroyed to recover a hung one.
|
|
474
|
-
if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
|
|
475
|
-
let winner;
|
|
476
|
-
try {
|
|
477
|
-
winner = await Promise.any(attempts.map((attempt) => attempt.promise));
|
|
478
|
-
} catch (error) {
|
|
479
|
-
const errors = Array.isArray(error?.errors) ? error.errors : [];
|
|
480
|
-
lastError = errors.at(-1) ?? lastError ?? error;
|
|
481
|
-
throw lastError;
|
|
482
|
-
}
|
|
483
|
-
cleanupAttempts(winner);
|
|
484
|
-
return winner;
|
|
485
|
-
} catch (error) {
|
|
486
|
-
cleanupAttempts();
|
|
487
|
-
if (isAbortError(error) || signal?.aborted) throw abortError();
|
|
488
|
-
lastError = error ?? lastError;
|
|
489
|
-
const visibility = document.visibilityState || "unknown";
|
|
490
|
-
const online = globalThis.navigator?.onLine === false ? "offline" : "online";
|
|
491
|
-
const phase = lastError?.message || "unknown";
|
|
492
|
-
const summary =
|
|
493
|
-
phase === "图片请求失败"
|
|
494
|
-
? "图片加载失败"
|
|
495
|
-
: phase.includes("超时")
|
|
496
|
-
? "等待图片加载超时"
|
|
497
|
-
: "图片校验失败";
|
|
498
|
-
throw new Error(
|
|
499
|
-
`${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
|
|
500
|
-
);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function safeOrigin(value) {
|
|
505
|
-
try {
|
|
506
|
-
return new URL(value, globalThis.location?.href).origin;
|
|
507
|
-
} catch {
|
|
508
|
-
return "unknown";
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
function videoRequestContext(url) {
|
|
513
|
-
return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) {
|
|
517
|
-
throwIfAborted(signal);
|
|
518
|
-
const controller = new AbortController();
|
|
519
|
-
const parentAborted = () => controller.abort();
|
|
520
|
-
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
521
|
-
let timedOut = false;
|
|
522
|
-
const timer = setTimeout(() => {
|
|
523
|
-
timedOut = true;
|
|
524
|
-
controller.abort();
|
|
525
|
-
}, Math.max(1, timeoutMs));
|
|
526
|
-
try {
|
|
527
|
-
const response = await fetch(url, {
|
|
528
|
-
method: "GET",
|
|
529
|
-
headers: { Range: "bytes=0-1" },
|
|
530
|
-
cache: "no-store",
|
|
531
|
-
credentials: "omit",
|
|
532
|
-
mode: "cors",
|
|
533
|
-
signal: controller.signal,
|
|
534
|
-
});
|
|
535
|
-
if (response.status !== 206) {
|
|
536
|
-
if (response.body) {
|
|
537
|
-
await response.body.cancel().catch(() => {});
|
|
538
|
-
}
|
|
539
|
-
throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`);
|
|
540
|
-
}
|
|
541
|
-
const bytes = await response.arrayBuffer();
|
|
542
|
-
if (bytes.byteLength !== 2) {
|
|
543
|
-
throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`);
|
|
544
|
-
}
|
|
545
|
-
} catch (error) {
|
|
546
|
-
if (signal?.aborted) throw abortError();
|
|
547
|
-
const context = videoRequestContext(url);
|
|
548
|
-
if (timedOut || isAbortError(error)) {
|
|
549
|
-
throw new Error(`视频媒体 Range 探针超时;${context}`);
|
|
550
|
-
}
|
|
551
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
552
|
-
if (error instanceof TypeError) {
|
|
553
|
-
throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`);
|
|
554
|
-
}
|
|
555
|
-
throw new Error(`${detail};${context}`);
|
|
556
|
-
} finally {
|
|
557
|
-
clearTimeout(timer);
|
|
558
|
-
signal?.removeEventListener?.("abort", parentAborted);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
|
|
346
|
+
function abortError() {
|
|
347
|
+
try {
|
|
348
|
+
return new DOMException("Background apply superseded", "AbortError");
|
|
349
|
+
} catch {
|
|
350
|
+
const error = new Error("Background apply superseded");
|
|
351
|
+
error.name = "AbortError";
|
|
352
|
+
return error;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isAbortError(error) {
|
|
357
|
+
return error?.name === "AbortError";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function throwIfAborted(signal) {
|
|
361
|
+
if (signal?.aborted) throw abortError();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function releaseImage(image, { remove = false } = {}) {
|
|
365
|
+
if (!image) return;
|
|
366
|
+
image.onload = null;
|
|
367
|
+
image.onerror = null;
|
|
368
|
+
try {
|
|
369
|
+
image.removeAttribute?.("src");
|
|
370
|
+
} catch {
|
|
371
|
+
image.src = "";
|
|
372
|
+
}
|
|
373
|
+
if (remove) image.remove?.();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function imageAttemptUrl(url, attempt) {
|
|
377
|
+
if (attempt === 0) return url;
|
|
378
|
+
try {
|
|
379
|
+
const retryUrl = new URL(url, globalThis.location?.href);
|
|
380
|
+
retryUrl.searchParams.set(
|
|
381
|
+
"bcImageRetry",
|
|
382
|
+
`${clientId}-${attempt}-${Date.now().toString(36)}`,
|
|
383
|
+
);
|
|
384
|
+
return retryUrl.href;
|
|
385
|
+
} catch {
|
|
386
|
+
return url;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function waitForImageAttempt(image, url, signal, timeoutMs) {
|
|
391
|
+
return new Promise((resolve, reject) => {
|
|
392
|
+
let settled = false;
|
|
393
|
+
let decodeStarted = false;
|
|
394
|
+
let decodeFallback = null;
|
|
395
|
+
const cleanup = () => {
|
|
396
|
+
clearTimeout(timer);
|
|
397
|
+
if (decodeFallback) clearTimeout(decodeFallback);
|
|
398
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
399
|
+
image.onload = null;
|
|
400
|
+
image.onerror = null;
|
|
401
|
+
};
|
|
402
|
+
const finish = (error = null) => {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
settled = true;
|
|
405
|
+
cleanup();
|
|
406
|
+
if (error) reject(error);
|
|
407
|
+
else resolve();
|
|
408
|
+
};
|
|
409
|
+
const acceptLoaded = () => {
|
|
410
|
+
if (!(image.complete && image.naturalWidth > 0 && image.naturalHeight > 0)) {
|
|
411
|
+
if (image.complete) finish(new Error("图片尺寸无效"));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (!decodeStarted && typeof image.decode === "function") {
|
|
415
|
+
decodeStarted = true;
|
|
416
|
+
// decode() is a second readiness signal, but the per-attempt timer
|
|
417
|
+
// remains authoritative. A loaded image is still accepted after a
|
|
418
|
+
// short decode grace period if Chromium leaves decode() pending.
|
|
419
|
+
decodeFallback = setTimeout(() => {
|
|
420
|
+
if (
|
|
421
|
+
image.isConnected === true &&
|
|
422
|
+
image.complete &&
|
|
423
|
+
image.naturalWidth > 0 &&
|
|
424
|
+
image.naturalHeight > 0
|
|
425
|
+
) {
|
|
426
|
+
finish();
|
|
427
|
+
} else {
|
|
428
|
+
finish(new Error("图片解码未完成"));
|
|
429
|
+
}
|
|
430
|
+
}, 750);
|
|
431
|
+
let decodePromise;
|
|
432
|
+
try {
|
|
433
|
+
decodePromise = image.decode();
|
|
434
|
+
} catch {
|
|
435
|
+
finish(new Error("图片解码失败"));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
Promise.resolve(decodePromise).then(
|
|
439
|
+
() => finish(),
|
|
440
|
+
() => finish(new Error("图片解码失败")),
|
|
441
|
+
);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
finish();
|
|
445
|
+
};
|
|
446
|
+
const failed = () => finish(new Error("图片请求失败"));
|
|
447
|
+
const aborted = () => finish(abortError());
|
|
448
|
+
const timer = setTimeout(
|
|
449
|
+
() => finish(new Error("等待图片请求或解码超时")),
|
|
450
|
+
timeoutMs,
|
|
451
|
+
);
|
|
452
|
+
image.onload = acceptLoaded;
|
|
453
|
+
image.onerror = failed;
|
|
454
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
455
|
+
if (signal?.aborted) {
|
|
456
|
+
aborted();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
image.src = url;
|
|
460
|
+
if (image.complete) queueMicrotask(acceptLoaded);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function waitForRetryWindow(signal, timeoutMs) {
|
|
465
|
+
return new Promise((resolve, reject) => {
|
|
466
|
+
let settled = false;
|
|
467
|
+
const finish = (error = null) => {
|
|
468
|
+
if (settled) return;
|
|
469
|
+
settled = true;
|
|
470
|
+
clearTimeout(timer);
|
|
471
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
472
|
+
if (error) reject(error);
|
|
473
|
+
else resolve();
|
|
474
|
+
};
|
|
475
|
+
const aborted = () => finish(abortError());
|
|
476
|
+
const timer = setTimeout(() => finish(), timeoutMs);
|
|
477
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
478
|
+
if (signal?.aborted) aborted();
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function loadImage(slot, url, signal, timeoutMs = IMAGE_LOAD_TIMEOUT_MS) {
|
|
483
|
+
const deadline = performance.now() + timeoutMs;
|
|
484
|
+
const attempts = [];
|
|
485
|
+
let lastError = null;
|
|
486
|
+
|
|
487
|
+
const startAttempt = (attempt) => {
|
|
488
|
+
throwIfAborted(signal);
|
|
489
|
+
const remainingMs = deadline - performance.now();
|
|
490
|
+
if (remainingMs <= 0) throw new Error("等待图片请求或解码超时");
|
|
491
|
+
const controller = new AbortController();
|
|
492
|
+
const parentAborted = () => controller.abort();
|
|
493
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
494
|
+
const image = new Image();
|
|
495
|
+
image.alt = "";
|
|
496
|
+
image.decoding = "async";
|
|
497
|
+
image.className = "beauticode-media-poster";
|
|
498
|
+
slot.prepend(image);
|
|
499
|
+
const promise = waitForImageAttempt(
|
|
500
|
+
image,
|
|
501
|
+
imageAttemptUrl(url, attempt),
|
|
502
|
+
controller.signal,
|
|
503
|
+
Math.max(1, remainingMs),
|
|
504
|
+
)
|
|
505
|
+
.then(() => image)
|
|
506
|
+
.finally(() => signal?.removeEventListener?.("abort", parentAborted));
|
|
507
|
+
const record = { controller, image, promise };
|
|
508
|
+
attempts.push(record);
|
|
509
|
+
return record;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const cleanupAttempts = (winner = null) => {
|
|
513
|
+
for (const attempt of attempts) {
|
|
514
|
+
if (attempt.image === winner) continue;
|
|
515
|
+
attempt.controller.abort();
|
|
516
|
+
releaseImage(attempt.image, { remove: true });
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
const first = startAttempt(0);
|
|
522
|
+
const firstWindow = await Promise.race([
|
|
523
|
+
first.promise.then(
|
|
524
|
+
(image) => ({ image }),
|
|
525
|
+
(error) => ({ error }),
|
|
526
|
+
),
|
|
527
|
+
waitForRetryWindow(
|
|
528
|
+
signal,
|
|
529
|
+
Math.max(1, Math.min(IMAGE_ATTEMPT_TIMEOUT_MS, deadline - performance.now())),
|
|
530
|
+
).then(() => ({ retry: true })),
|
|
531
|
+
]);
|
|
532
|
+
if (firstWindow.image) {
|
|
533
|
+
cleanupAttempts(firstWindow.image);
|
|
534
|
+
return firstWindow.image;
|
|
535
|
+
}
|
|
536
|
+
if (isAbortError(firstWindow.error) || signal?.aborted) throw abortError();
|
|
537
|
+
lastError = firstWindow.error ?? lastError;
|
|
538
|
+
|
|
539
|
+
// Keep the original request alive. The cache-busted retry runs beside it
|
|
540
|
+
// so a merely slow cold decode is never destroyed to recover a hung one.
|
|
541
|
+
if (IMAGE_MAX_ATTEMPTS > 1) startAttempt(1);
|
|
542
|
+
let winner;
|
|
543
|
+
try {
|
|
544
|
+
winner = await Promise.any(attempts.map((attempt) => attempt.promise));
|
|
545
|
+
} catch (error) {
|
|
546
|
+
const errors = Array.isArray(error?.errors) ? error.errors : [];
|
|
547
|
+
lastError = errors.at(-1) ?? lastError ?? error;
|
|
548
|
+
throw lastError;
|
|
549
|
+
}
|
|
550
|
+
cleanupAttempts(winner);
|
|
551
|
+
return winner;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
cleanupAttempts();
|
|
554
|
+
if (isAbortError(error) || signal?.aborted) throw abortError();
|
|
555
|
+
lastError = error ?? lastError;
|
|
556
|
+
const visibility = document.visibilityState || "unknown";
|
|
557
|
+
const online = globalThis.navigator?.onLine === false ? "offline" : "online";
|
|
558
|
+
const phase = lastError?.message || "unknown";
|
|
559
|
+
const summary =
|
|
560
|
+
phase === "图片请求失败"
|
|
561
|
+
? "图片加载失败"
|
|
562
|
+
: phase.includes("超时")
|
|
563
|
+
? "等待图片加载超时"
|
|
564
|
+
: "图片校验失败";
|
|
565
|
+
throw new Error(
|
|
566
|
+
`${summary}(已尝试 ${attempts.length} 次;页面=${visibility};网络=${online};最后阶段=${phase})`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function safeOrigin(value) {
|
|
572
|
+
try {
|
|
573
|
+
return new URL(value, globalThis.location?.href).origin;
|
|
574
|
+
} catch {
|
|
575
|
+
return "unknown";
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function videoRequestContext(url) {
|
|
580
|
+
return `页面Origin=${safeOrigin(globalThis.location?.href)};媒体Origin=${safeOrigin(url)};页面=${document.visibilityState || "unknown"}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function probeVideoSource(url, signal, timeoutMs = VIDEO_PROBE_TIMEOUT_MS) {
|
|
584
|
+
throwIfAborted(signal);
|
|
585
|
+
const controller = new AbortController();
|
|
586
|
+
const parentAborted = () => controller.abort();
|
|
587
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
588
|
+
let timedOut = false;
|
|
589
|
+
const timer = setTimeout(() => {
|
|
590
|
+
timedOut = true;
|
|
591
|
+
controller.abort();
|
|
592
|
+
}, Math.max(1, timeoutMs));
|
|
593
|
+
try {
|
|
594
|
+
const response = await fetch(url, {
|
|
595
|
+
method: "GET",
|
|
596
|
+
headers: { Range: "bytes=0-1" },
|
|
597
|
+
cache: "no-store",
|
|
598
|
+
credentials: "omit",
|
|
599
|
+
mode: "cors",
|
|
600
|
+
signal: controller.signal,
|
|
601
|
+
});
|
|
602
|
+
if (response.status !== 206) {
|
|
603
|
+
if (response.body) {
|
|
604
|
+
await response.body.cancel().catch(() => {});
|
|
605
|
+
}
|
|
606
|
+
throw new Error(`视频媒体 Range 探针返回 HTTP ${response.status}`);
|
|
607
|
+
}
|
|
608
|
+
const bytes = await response.arrayBuffer();
|
|
609
|
+
if (bytes.byteLength !== 2) {
|
|
610
|
+
throw new Error(`视频媒体 Range 探针返回 ${bytes.byteLength} 字节`);
|
|
611
|
+
}
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (signal?.aborted) throw abortError();
|
|
614
|
+
const context = videoRequestContext(url);
|
|
615
|
+
if (timedOut || isAbortError(error)) {
|
|
616
|
+
throw new Error(`视频媒体 Range 探针超时;${context}`);
|
|
617
|
+
}
|
|
618
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
619
|
+
if (error instanceof TypeError) {
|
|
620
|
+
throw new Error(`视频媒体不可达或被 CORS 拒绝;${context};${detail}`);
|
|
621
|
+
}
|
|
622
|
+
throw new Error(`${detail};${context}`);
|
|
623
|
+
} finally {
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
signal?.removeEventListener?.("abort", parentAborted);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const videoEventLog = new WeakMap();
|
|
630
|
+
const VIDEO_DIAGNOSTIC_EVENTS = [
|
|
631
|
+
"loadstart",
|
|
632
|
+
"durationchange",
|
|
633
|
+
"loadedmetadata",
|
|
634
|
+
"loadeddata",
|
|
635
|
+
"progress",
|
|
636
|
+
"canplay",
|
|
637
|
+
"canplaythrough",
|
|
638
|
+
"playing",
|
|
639
|
+
"waiting",
|
|
640
|
+
"stalled",
|
|
641
|
+
"suspend",
|
|
642
|
+
"emptied",
|
|
643
|
+
"error",
|
|
644
|
+
"abort",
|
|
645
|
+
];
|
|
646
|
+
|
|
647
|
+
// Field reports only used to show the final readyState. Recording when
|
|
648
|
+
// each media event fired turns a bare timeout into a stage timeline: no
|
|
649
|
+
// events at all means the request never started, loadstart-then-nothing
|
|
650
|
+
// means the first range reads stalled (antivirus / cold cache).
|
|
651
|
+
function trackVideoDiagnostics(video) {
|
|
652
|
+
if (!video || typeof video.addEventListener !== "function") return;
|
|
653
|
+
if (videoEventLog.has(video)) return;
|
|
654
|
+
const log = [];
|
|
655
|
+
videoEventLog.set(video, log);
|
|
656
|
+
for (const name of VIDEO_DIAGNOSTIC_EVENTS) {
|
|
657
|
+
video.addEventListener(name, () => {
|
|
658
|
+
log.push(`${name}@${Math.round(performance.now())}ms`);
|
|
659
|
+
if (log.length > 14) log.splice(0, log.length - 14);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function describeVideoDiagnostics(video) {
|
|
665
|
+
const log = videoEventLog.get(video);
|
|
666
|
+
if (!Array.isArray(log) || log.length === 0) {
|
|
667
|
+
return "媒体事件=无(媒体请求未开始)";
|
|
668
|
+
}
|
|
669
|
+
return `媒体事件=${log.join(",")}`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function describeVideoState(video, phase) {
|
|
563
673
|
const mediaErrorNames = {
|
|
564
674
|
1: "MEDIA_ERR_ABORTED",
|
|
565
675
|
2: "MEDIA_ERR_NETWORK",
|
|
@@ -568,195 +678,202 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
568
678
|
};
|
|
569
679
|
const code = Number(video?.error?.code) || 0;
|
|
570
680
|
const mediaError = code ? mediaErrorNames[code] || `MEDIA_ERR_${code}` : "none";
|
|
571
|
-
const src = video?.currentSrc || video?.src || "";
|
|
572
|
-
return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)};${videoRequestContext(src)}`;
|
|
681
|
+
const src = video?.currentSrc || video?.src || "";
|
|
682
|
+
return `${phase};mediaError=${mediaError};readyState=${video?.readyState ?? -1};networkState=${video?.networkState ?? -1};paused=${Boolean(video?.paused)};${videoRequestContext(src)};${describeVideoDiagnostics(video)}`;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function waitForVideo(video, signal, timeoutMs = VIDEO_STARTUP_TIMEOUT_MS) {
|
|
686
|
+
const frameReadyState = HTMLMediaElement.HAVE_CURRENT_DATA ?? 2;
|
|
687
|
+
if (video.readyState >= frameReadyState) return Promise.resolve();
|
|
688
|
+
// A decoder error may fire before this waiter attaches — most notably
|
|
689
|
+
// between the poster commit's crossfade and the settle start. Honoring an
|
|
690
|
+
// already-set error keeps the settle phase from waiting out its full
|
|
691
|
+
// budget on media that has already failed.
|
|
692
|
+
if (video.error) {
|
|
693
|
+
return Promise.reject(new Error(describeVideoState(video, "MP4 加载或解码失败")));
|
|
694
|
+
}
|
|
695
|
+
return new Promise((resolve, reject) => {
|
|
696
|
+
let settled = false;
|
|
697
|
+
const done = () => finish();
|
|
698
|
+
const check = () => {
|
|
699
|
+
if (video.readyState >= frameReadyState) done();
|
|
700
|
+
};
|
|
701
|
+
const failed = () => {
|
|
702
|
+
finish(new Error(describeVideoState(video, "MP4 加载或解码失败")));
|
|
703
|
+
};
|
|
704
|
+
const aborted = () => {
|
|
705
|
+
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频加载已中止")));
|
|
706
|
+
};
|
|
707
|
+
const timer = setTimeout(() => {
|
|
708
|
+
finish(new Error(describeVideoState(video, "等待视频首帧超时")));
|
|
709
|
+
}, timeoutMs);
|
|
710
|
+
const cleanup = () => {
|
|
711
|
+
clearTimeout(timer);
|
|
712
|
+
video.removeEventListener("loadeddata", check);
|
|
713
|
+
video.removeEventListener("canplay", done);
|
|
714
|
+
video.removeEventListener("error", failed);
|
|
715
|
+
video.removeEventListener("abort", aborted);
|
|
716
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
717
|
+
};
|
|
718
|
+
const finish = (error) => {
|
|
719
|
+
if (settled) return;
|
|
720
|
+
settled = true;
|
|
721
|
+
cleanup();
|
|
722
|
+
if (error) reject(error);
|
|
723
|
+
else resolve();
|
|
724
|
+
};
|
|
725
|
+
video.addEventListener("loadeddata", check);
|
|
726
|
+
video.addEventListener("canplay", done, { once: true });
|
|
727
|
+
video.addEventListener("error", failed, { once: true });
|
|
728
|
+
video.addEventListener("abort", aborted, { once: true });
|
|
729
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
730
|
+
if (signal?.aborted) aborted();
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function waitForSeek(video, signal, timeoutMs) {
|
|
735
|
+
if (!video.seeking) return Promise.resolve();
|
|
736
|
+
return new Promise((resolve, reject) => {
|
|
737
|
+
let settled = false;
|
|
738
|
+
const cleanup = () => {
|
|
739
|
+
clearTimeout(timer);
|
|
740
|
+
video.removeEventListener("seeked", done);
|
|
741
|
+
video.removeEventListener("error", failed);
|
|
742
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
743
|
+
};
|
|
744
|
+
const finish = (error) => {
|
|
745
|
+
if (settled) return;
|
|
746
|
+
settled = true;
|
|
747
|
+
cleanup();
|
|
748
|
+
if (error) reject(error);
|
|
749
|
+
else resolve();
|
|
750
|
+
};
|
|
751
|
+
const done = () => finish();
|
|
752
|
+
const failed = () => finish(new Error(describeVideoState(video, "视频跳转失败")));
|
|
753
|
+
const aborted = () => finish(abortError());
|
|
754
|
+
const timer = setTimeout(
|
|
755
|
+
() => finish(new Error(describeVideoState(video, "等待视频跳转超时"))),
|
|
756
|
+
timeoutMs,
|
|
757
|
+
);
|
|
758
|
+
video.addEventListener("seeked", done, { once: true });
|
|
759
|
+
video.addEventListener("error", failed, { once: true });
|
|
760
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
761
|
+
if (signal?.aborted) aborted();
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function waitForPresentedFrame(video, signal, timeoutMs) {
|
|
766
|
+
return new Promise((resolve, reject) => {
|
|
767
|
+
const startedAt = performance.now();
|
|
768
|
+
const initialTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
|
|
769
|
+
let frameSeen = false;
|
|
770
|
+
let playingSeen =
|
|
771
|
+
!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
772
|
+
let settled = false;
|
|
773
|
+
let frameRequest = null;
|
|
774
|
+
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
775
|
+
|
|
776
|
+
const cleanup = () => {
|
|
777
|
+
clearInterval(timer);
|
|
778
|
+
video.removeEventListener("playing", onPlaying);
|
|
779
|
+
video.removeEventListener("timeupdate", check);
|
|
780
|
+
video.removeEventListener("loadeddata", check);
|
|
781
|
+
video.removeEventListener("canplay", check);
|
|
782
|
+
video.removeEventListener("error", failed);
|
|
783
|
+
video.removeEventListener("abort", aborted);
|
|
784
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
785
|
+
if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
786
|
+
video.cancelVideoFrameCallback(frameRequest);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
const finish = (error) => {
|
|
790
|
+
if (settled) return;
|
|
791
|
+
settled = true;
|
|
792
|
+
cleanup();
|
|
793
|
+
if (error) reject(error);
|
|
794
|
+
else resolve();
|
|
795
|
+
};
|
|
796
|
+
const failed = () => {
|
|
797
|
+
finish(new Error(describeVideoState(video, "视频解码器报告失败")));
|
|
798
|
+
};
|
|
799
|
+
const aborted = () => {
|
|
800
|
+
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
|
|
801
|
+
};
|
|
802
|
+
const check = () => {
|
|
803
|
+
if (settled) return;
|
|
804
|
+
if (video.error) {
|
|
805
|
+
failed();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
809
|
+
playingSeen = true;
|
|
810
|
+
}
|
|
811
|
+
const current = Number.isFinite(video.currentTime) ? video.currentTime : initialTime;
|
|
812
|
+
const progressed = current >= initialTime + VIDEO_FIRST_FRAME_PROGRESS_SEC;
|
|
813
|
+
if (
|
|
814
|
+
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
|
815
|
+
!video.paused &&
|
|
816
|
+
playingSeen &&
|
|
817
|
+
(frameSeen || progressed)
|
|
818
|
+
) {
|
|
819
|
+
finish();
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (performance.now() - startedAt >= timeoutMs) {
|
|
823
|
+
finish(new Error(describeVideoState(video, "视频未在首帧窗口内完成呈现")));
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
const onPlaying = () => {
|
|
827
|
+
playingSeen = true;
|
|
828
|
+
check();
|
|
829
|
+
};
|
|
830
|
+
const onFrame = () => {
|
|
831
|
+
frameSeen = true;
|
|
832
|
+
check();
|
|
833
|
+
};
|
|
834
|
+
const timer = setInterval(check, 80);
|
|
835
|
+
video.addEventListener("playing", onPlaying);
|
|
836
|
+
video.addEventListener("timeupdate", check);
|
|
837
|
+
video.addEventListener("loadeddata", check);
|
|
838
|
+
video.addEventListener("canplay", check);
|
|
839
|
+
video.addEventListener("error", failed, { once: true });
|
|
840
|
+
video.addEventListener("abort", aborted, { once: true });
|
|
841
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
842
|
+
if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
|
|
843
|
+
if (signal?.aborted) aborted();
|
|
844
|
+
check();
|
|
845
|
+
});
|
|
573
846
|
}
|
|
574
847
|
|
|
575
|
-
function
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
let
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
video.removeEventListener("
|
|
596
|
-
video.removeEventListener("
|
|
597
|
-
video.removeEventListener("
|
|
598
|
-
video.removeEventListener("
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
cleanup();
|
|
605
|
-
if (error) reject(error);
|
|
606
|
-
else resolve();
|
|
607
|
-
};
|
|
608
|
-
video.addEventListener("loadeddata", check);
|
|
609
|
-
video.addEventListener("canplay", done, { once: true });
|
|
610
|
-
video.addEventListener("error", failed, { once: true });
|
|
611
|
-
video.addEventListener("abort", aborted, { once: true });
|
|
612
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
613
|
-
if (signal?.aborted) aborted();
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function waitForSeek(video, signal, timeoutMs) {
|
|
618
|
-
if (!video.seeking) return Promise.resolve();
|
|
619
|
-
return new Promise((resolve, reject) => {
|
|
620
|
-
let settled = false;
|
|
621
|
-
const cleanup = () => {
|
|
622
|
-
clearTimeout(timer);
|
|
623
|
-
video.removeEventListener("seeked", done);
|
|
624
|
-
video.removeEventListener("error", failed);
|
|
625
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
626
|
-
};
|
|
627
|
-
const finish = (error) => {
|
|
628
|
-
if (settled) return;
|
|
629
|
-
settled = true;
|
|
630
|
-
cleanup();
|
|
631
|
-
if (error) reject(error);
|
|
632
|
-
else resolve();
|
|
633
|
-
};
|
|
634
|
-
const done = () => finish();
|
|
635
|
-
const failed = () => finish(new Error(describeVideoState(video, "视频跳转失败")));
|
|
636
|
-
const aborted = () => finish(abortError());
|
|
637
|
-
const timer = setTimeout(
|
|
638
|
-
() => finish(new Error(describeVideoState(video, "等待视频跳转超时"))),
|
|
639
|
-
timeoutMs,
|
|
640
|
-
);
|
|
641
|
-
video.addEventListener("seeked", done, { once: true });
|
|
642
|
-
video.addEventListener("error", failed, { once: true });
|
|
643
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
644
|
-
if (signal?.aborted) aborted();
|
|
645
|
-
});
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
function waitForPresentedFrame(video, signal, timeoutMs) {
|
|
649
|
-
return new Promise((resolve, reject) => {
|
|
650
|
-
const startedAt = performance.now();
|
|
651
|
-
const initialTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
|
|
652
|
-
let frameSeen = false;
|
|
653
|
-
let playingSeen =
|
|
654
|
-
!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
655
|
-
let settled = false;
|
|
656
|
-
let frameRequest = null;
|
|
657
|
-
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
658
|
-
|
|
659
|
-
const cleanup = () => {
|
|
660
|
-
clearInterval(timer);
|
|
661
|
-
video.removeEventListener("playing", onPlaying);
|
|
662
|
-
video.removeEventListener("timeupdate", check);
|
|
663
|
-
video.removeEventListener("loadeddata", check);
|
|
664
|
-
video.removeEventListener("canplay", check);
|
|
665
|
-
video.removeEventListener("error", failed);
|
|
666
|
-
video.removeEventListener("abort", aborted);
|
|
667
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
668
|
-
if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
669
|
-
video.cancelVideoFrameCallback(frameRequest);
|
|
670
|
-
}
|
|
671
|
-
};
|
|
672
|
-
const finish = (error) => {
|
|
673
|
-
if (settled) return;
|
|
674
|
-
settled = true;
|
|
675
|
-
cleanup();
|
|
676
|
-
if (error) reject(error);
|
|
677
|
-
else resolve();
|
|
678
|
-
};
|
|
679
|
-
const failed = () => {
|
|
680
|
-
finish(new Error(describeVideoState(video, "视频解码器报告失败")));
|
|
681
|
-
};
|
|
682
|
-
const aborted = () => {
|
|
683
|
-
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
|
|
684
|
-
};
|
|
685
|
-
const check = () => {
|
|
686
|
-
if (settled) return;
|
|
687
|
-
if (video.error) {
|
|
688
|
-
failed();
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
692
|
-
playingSeen = true;
|
|
693
|
-
}
|
|
694
|
-
const current = Number.isFinite(video.currentTime) ? video.currentTime : initialTime;
|
|
695
|
-
const progressed = current >= initialTime + VIDEO_FIRST_FRAME_PROGRESS_SEC;
|
|
696
|
-
if (
|
|
697
|
-
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
|
698
|
-
!video.paused &&
|
|
699
|
-
playingSeen &&
|
|
700
|
-
(frameSeen || progressed)
|
|
701
|
-
) {
|
|
702
|
-
finish();
|
|
703
|
-
return;
|
|
704
|
-
}
|
|
705
|
-
if (performance.now() - startedAt >= timeoutMs) {
|
|
706
|
-
finish(new Error(describeVideoState(video, "视频未在首帧窗口内完成呈现")));
|
|
707
|
-
}
|
|
708
|
-
};
|
|
709
|
-
const onPlaying = () => {
|
|
710
|
-
playingSeen = true;
|
|
711
|
-
check();
|
|
712
|
-
};
|
|
713
|
-
const onFrame = () => {
|
|
714
|
-
frameSeen = true;
|
|
715
|
-
check();
|
|
716
|
-
};
|
|
717
|
-
const timer = setInterval(check, 80);
|
|
718
|
-
video.addEventListener("playing", onPlaying);
|
|
719
|
-
video.addEventListener("timeupdate", check);
|
|
720
|
-
video.addEventListener("loadeddata", check);
|
|
721
|
-
video.addEventListener("canplay", check);
|
|
722
|
-
video.addEventListener("error", failed, { once: true });
|
|
723
|
-
video.addEventListener("abort", aborted, { once: true });
|
|
724
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
725
|
-
if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
|
|
726
|
-
if (signal?.aborted) aborted();
|
|
727
|
-
check();
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
function waitForStablePlayback(video, signal, timeoutMs) {
|
|
732
|
-
return new Promise((resolve, reject) => {
|
|
733
|
-
const startedAt = performance.now();
|
|
734
|
-
let lastTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
|
|
735
|
-
let accumulatedProgress = 0;
|
|
736
|
-
let advancingSamples = 0;
|
|
737
|
-
let stableFrames = 0;
|
|
738
|
-
let lastFrameTime = null;
|
|
739
|
-
let playingSeen =
|
|
740
|
-
!video.paused &&
|
|
741
|
-
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
742
|
-
let settled = false;
|
|
743
|
-
let frameRequest = null;
|
|
744
|
-
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
745
|
-
|
|
746
|
-
const cleanup = () => {
|
|
747
|
-
clearInterval(timer);
|
|
748
|
-
video.removeEventListener("playing", onPlaying);
|
|
749
|
-
video.removeEventListener("timeupdate", check);
|
|
750
|
-
video.removeEventListener("waiting", resetStableWindow);
|
|
751
|
-
video.removeEventListener("stalled", resetStableWindow);
|
|
752
|
-
video.removeEventListener("pause", resetStableWindow);
|
|
753
|
-
video.removeEventListener("seeking", resetStableWindow);
|
|
754
|
-
video.removeEventListener("error", failed);
|
|
755
|
-
video.removeEventListener("abort", aborted);
|
|
756
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
757
|
-
if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
758
|
-
video.cancelVideoFrameCallback(frameRequest);
|
|
759
|
-
}
|
|
848
|
+
function waitForStablePlayback(video, signal, timeoutMs) {
|
|
849
|
+
return new Promise((resolve, reject) => {
|
|
850
|
+
const startedAt = performance.now();
|
|
851
|
+
let lastTime = Number.isFinite(video.currentTime) ? video.currentTime : 0;
|
|
852
|
+
let accumulatedProgress = 0;
|
|
853
|
+
let advancingSamples = 0;
|
|
854
|
+
let stableFrames = 0;
|
|
855
|
+
let lastFrameTime = null;
|
|
856
|
+
let playingSeen =
|
|
857
|
+
!video.paused &&
|
|
858
|
+
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA;
|
|
859
|
+
let settled = false;
|
|
860
|
+
let frameRequest = null;
|
|
861
|
+
const hasFrameCallback = typeof video.requestVideoFrameCallback === "function";
|
|
862
|
+
|
|
863
|
+
const cleanup = () => {
|
|
864
|
+
clearInterval(timer);
|
|
865
|
+
video.removeEventListener("playing", onPlaying);
|
|
866
|
+
video.removeEventListener("timeupdate", check);
|
|
867
|
+
video.removeEventListener("waiting", resetStableWindow);
|
|
868
|
+
video.removeEventListener("stalled", resetStableWindow);
|
|
869
|
+
video.removeEventListener("pause", resetStableWindow);
|
|
870
|
+
video.removeEventListener("seeking", resetStableWindow);
|
|
871
|
+
video.removeEventListener("error", failed);
|
|
872
|
+
video.removeEventListener("abort", aborted);
|
|
873
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
874
|
+
if (frameRequest != null && typeof video.cancelVideoFrameCallback === "function") {
|
|
875
|
+
video.cancelVideoFrameCallback(frameRequest);
|
|
876
|
+
}
|
|
760
877
|
};
|
|
761
878
|
const finish = (error) => {
|
|
762
879
|
if (settled) return;
|
|
@@ -765,57 +882,57 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
765
882
|
if (error) reject(error);
|
|
766
883
|
else resolve();
|
|
767
884
|
};
|
|
768
|
-
const failed = () => {
|
|
769
|
-
finish(new Error(describeVideoState(video, "视频解码器报告失败")));
|
|
770
|
-
};
|
|
771
|
-
const aborted = () => {
|
|
772
|
-
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
|
|
773
|
-
};
|
|
774
|
-
const observeProgress = () => {
|
|
775
|
-
const current = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
|
|
776
|
-
let delta = current - lastTime;
|
|
777
|
-
if (
|
|
778
|
-
delta < 0 &&
|
|
779
|
-
Number.isFinite(video.duration) &&
|
|
780
|
-
video.duration > 0 &&
|
|
781
|
-
lastTime > video.duration - 1 &&
|
|
782
|
-
current < 1
|
|
783
|
-
) {
|
|
784
|
-
delta = video.duration - lastTime + current;
|
|
785
|
-
}
|
|
786
|
-
if (delta > 0.003 && delta < 2) {
|
|
787
|
-
accumulatedProgress += delta;
|
|
788
|
-
advancingSamples += 1;
|
|
789
|
-
}
|
|
790
|
-
lastTime = current;
|
|
791
|
-
};
|
|
792
|
-
const resetStableWindow = () => {
|
|
793
|
-
playingSeen = false;
|
|
794
|
-
stableFrames = 0;
|
|
795
|
-
advancingSamples = 0;
|
|
796
|
-
accumulatedProgress = 0;
|
|
797
|
-
lastTime = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
|
|
798
|
-
};
|
|
799
|
-
const check = () => {
|
|
800
|
-
if (settled) return;
|
|
801
|
-
if (video.error) {
|
|
802
|
-
failed();
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
observeProgress();
|
|
806
|
-
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
807
|
-
playingSeen = true;
|
|
808
|
-
}
|
|
809
|
-
const framesReady = hasFrameCallback
|
|
810
|
-
? stableFrames >= VIDEO_STABLE_FRAMES
|
|
811
|
-
: advancingSamples >= VIDEO_STABLE_FRAMES;
|
|
812
|
-
if (
|
|
813
|
-
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
|
814
|
-
!video.paused &&
|
|
815
|
-
playingSeen &&
|
|
816
|
-
framesReady &&
|
|
817
|
-
accumulatedProgress >= VIDEO_STABLE_PROGRESS_SEC
|
|
818
|
-
) {
|
|
885
|
+
const failed = () => {
|
|
886
|
+
finish(new Error(describeVideoState(video, "视频解码器报告失败")));
|
|
887
|
+
};
|
|
888
|
+
const aborted = () => {
|
|
889
|
+
finish(signal?.aborted ? abortError() : new Error(describeVideoState(video, "视频播放已中止")));
|
|
890
|
+
};
|
|
891
|
+
const observeProgress = () => {
|
|
892
|
+
const current = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
|
|
893
|
+
let delta = current - lastTime;
|
|
894
|
+
if (
|
|
895
|
+
delta < 0 &&
|
|
896
|
+
Number.isFinite(video.duration) &&
|
|
897
|
+
video.duration > 0 &&
|
|
898
|
+
lastTime > video.duration - 1 &&
|
|
899
|
+
current < 1
|
|
900
|
+
) {
|
|
901
|
+
delta = video.duration - lastTime + current;
|
|
902
|
+
}
|
|
903
|
+
if (delta > 0.003 && delta < 2) {
|
|
904
|
+
accumulatedProgress += delta;
|
|
905
|
+
advancingSamples += 1;
|
|
906
|
+
}
|
|
907
|
+
lastTime = current;
|
|
908
|
+
};
|
|
909
|
+
const resetStableWindow = () => {
|
|
910
|
+
playingSeen = false;
|
|
911
|
+
stableFrames = 0;
|
|
912
|
+
advancingSamples = 0;
|
|
913
|
+
accumulatedProgress = 0;
|
|
914
|
+
lastTime = Number.isFinite(video.currentTime) ? video.currentTime : lastTime;
|
|
915
|
+
};
|
|
916
|
+
const check = () => {
|
|
917
|
+
if (settled) return;
|
|
918
|
+
if (video.error) {
|
|
919
|
+
failed();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
observeProgress();
|
|
923
|
+
if (!video.paused && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
924
|
+
playingSeen = true;
|
|
925
|
+
}
|
|
926
|
+
const framesReady = hasFrameCallback
|
|
927
|
+
? stableFrames >= VIDEO_STABLE_FRAMES
|
|
928
|
+
: advancingSamples >= VIDEO_STABLE_FRAMES;
|
|
929
|
+
if (
|
|
930
|
+
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
|
931
|
+
!video.paused &&
|
|
932
|
+
playingSeen &&
|
|
933
|
+
framesReady &&
|
|
934
|
+
accumulatedProgress >= VIDEO_STABLE_PROGRESS_SEC
|
|
935
|
+
) {
|
|
819
936
|
finish();
|
|
820
937
|
return;
|
|
821
938
|
}
|
|
@@ -823,39 +940,39 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
823
940
|
finish(new Error(describeVideoState(video, "视频未在稳定窗口内输出首帧")));
|
|
824
941
|
}
|
|
825
942
|
};
|
|
826
|
-
const onPlaying = () => {
|
|
827
|
-
playingSeen = true;
|
|
828
|
-
check();
|
|
829
|
-
};
|
|
830
|
-
const onFrame = (_now, metadata) => {
|
|
831
|
-
if (settled) return;
|
|
832
|
-
const mediaTime = Number.isFinite(metadata?.mediaTime)
|
|
833
|
-
? metadata.mediaTime
|
|
834
|
-
: video.currentTime;
|
|
835
|
-
if (lastFrameTime == null || mediaTime > lastFrameTime + 0.001) {
|
|
836
|
-
stableFrames += 1;
|
|
837
|
-
} else {
|
|
838
|
-
stableFrames = 0;
|
|
839
|
-
}
|
|
840
|
-
lastFrameTime = mediaTime;
|
|
841
|
-
check();
|
|
842
|
-
if (!settled) frameRequest = video.requestVideoFrameCallback(onFrame);
|
|
843
|
-
};
|
|
844
|
-
const timer = setInterval(check, 80);
|
|
845
|
-
video.addEventListener("playing", onPlaying);
|
|
846
|
-
video.addEventListener("timeupdate", check);
|
|
847
|
-
video.addEventListener("waiting", resetStableWindow);
|
|
848
|
-
video.addEventListener("stalled", resetStableWindow);
|
|
849
|
-
video.addEventListener("pause", resetStableWindow);
|
|
850
|
-
video.addEventListener("seeking", resetStableWindow);
|
|
851
|
-
video.addEventListener("error", failed, { once: true });
|
|
852
|
-
video.addEventListener("abort", aborted, { once: true });
|
|
853
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
854
|
-
if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
|
|
855
|
-
if (signal?.aborted) aborted();
|
|
856
|
-
check();
|
|
857
|
-
});
|
|
858
|
-
}
|
|
943
|
+
const onPlaying = () => {
|
|
944
|
+
playingSeen = true;
|
|
945
|
+
check();
|
|
946
|
+
};
|
|
947
|
+
const onFrame = (_now, metadata) => {
|
|
948
|
+
if (settled) return;
|
|
949
|
+
const mediaTime = Number.isFinite(metadata?.mediaTime)
|
|
950
|
+
? metadata.mediaTime
|
|
951
|
+
: video.currentTime;
|
|
952
|
+
if (lastFrameTime == null || mediaTime > lastFrameTime + 0.001) {
|
|
953
|
+
stableFrames += 1;
|
|
954
|
+
} else {
|
|
955
|
+
stableFrames = 0;
|
|
956
|
+
}
|
|
957
|
+
lastFrameTime = mediaTime;
|
|
958
|
+
check();
|
|
959
|
+
if (!settled) frameRequest = video.requestVideoFrameCallback(onFrame);
|
|
960
|
+
};
|
|
961
|
+
const timer = setInterval(check, 80);
|
|
962
|
+
video.addEventListener("playing", onPlaying);
|
|
963
|
+
video.addEventListener("timeupdate", check);
|
|
964
|
+
video.addEventListener("waiting", resetStableWindow);
|
|
965
|
+
video.addEventListener("stalled", resetStableWindow);
|
|
966
|
+
video.addEventListener("pause", resetStableWindow);
|
|
967
|
+
video.addEventListener("seeking", resetStableWindow);
|
|
968
|
+
video.addEventListener("error", failed, { once: true });
|
|
969
|
+
video.addEventListener("abort", aborted, { once: true });
|
|
970
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
971
|
+
if (hasFrameCallback) frameRequest = video.requestVideoFrameCallback(onFrame);
|
|
972
|
+
if (signal?.aborted) aborted();
|
|
973
|
+
check();
|
|
974
|
+
});
|
|
975
|
+
}
|
|
859
976
|
|
|
860
977
|
function seekVideo(video, value) {
|
|
861
978
|
const requested = Number(value);
|
|
@@ -866,504 +983,540 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
866
983
|
(!Number.isFinite(duration) || duration <= 0 || requested < duration)
|
|
867
984
|
? requested
|
|
868
985
|
: 0;
|
|
869
|
-
try {
|
|
870
|
-
video.currentTime = safe;
|
|
986
|
+
try {
|
|
987
|
+
video.currentTime = safe;
|
|
988
|
+
} catch {
|
|
989
|
+
video.currentTime = 0;
|
|
990
|
+
}
|
|
991
|
+
return safe;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function playWithPreference(video, signal = null) {
|
|
995
|
+
throwIfAborted(signal);
|
|
996
|
+
const requestedMuted = desiredModes.muted;
|
|
997
|
+
let blocked = false;
|
|
998
|
+
video.muted = requestedMuted;
|
|
999
|
+
try {
|
|
1000
|
+
await video.play();
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
throwIfAborted(signal);
|
|
1003
|
+
if (requestedMuted) throw error;
|
|
1004
|
+
blocked = true;
|
|
1005
|
+
video.muted = true;
|
|
1006
|
+
await video.play();
|
|
1007
|
+
}
|
|
1008
|
+
throwIfAborted(signal);
|
|
1009
|
+
video.dataset.bcPlaybackBlocked = blocked ? "true" : "false";
|
|
1010
|
+
return blocked;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Phase one only needs playback initiated. On a cold machine the muted
|
|
1014
|
+
// play() promise can stay pending well past the apply deadline while the
|
|
1015
|
+
// decoder warms; that is not a failure — the poster-first commit tolerates
|
|
1016
|
+
// it and the settle phase owns the eventual outcome. Only an outright
|
|
1017
|
+
// rejection (autoplay policy, detached element) fails the apply.
|
|
1018
|
+
async function startCandidatePlayback(video, signal, timeoutMs) {
|
|
1019
|
+
const playbackController = new AbortController();
|
|
1020
|
+
const parentAborted = () => playbackController.abort();
|
|
1021
|
+
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
1022
|
+
let acceptTimer = null;
|
|
1023
|
+
let abortListener = null;
|
|
1024
|
+
const accepted = new Promise((resolve) => {
|
|
1025
|
+
acceptTimer = setTimeout(() => resolve("accepted"), Math.max(1, timeoutMs));
|
|
1026
|
+
abortListener = () => resolve("aborted");
|
|
1027
|
+
playbackController.signal.addEventListener("abort", abortListener, { once: true });
|
|
1028
|
+
});
|
|
1029
|
+
if (signal?.aborted) parentAborted();
|
|
1030
|
+
const playback = playWithPreference(video, playbackController.signal);
|
|
1031
|
+
// The settle phase owns late outcomes of an accepted-but-unresolved play().
|
|
1032
|
+
void playback.catch?.(() => {});
|
|
1033
|
+
try {
|
|
1034
|
+
const outcome = await Promise.race([playback.then(() => "started"), accepted]);
|
|
1035
|
+
if (outcome === "aborted") throw abortError();
|
|
1036
|
+
return outcome;
|
|
1037
|
+
} finally {
|
|
1038
|
+
clearTimeout(acceptTimer);
|
|
1039
|
+
signal?.removeEventListener?.("abort", parentAborted);
|
|
1040
|
+
if (abortListener) {
|
|
1041
|
+
playbackController.signal.removeEventListener("abort", abortListener);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function disposeVideo(video) {
|
|
1047
|
+
if (!(video instanceof HTMLVideoElement)) return;
|
|
1048
|
+
try {
|
|
1049
|
+
video.pause();
|
|
1050
|
+
} catch {
|
|
1051
|
+
/* best effort */
|
|
1052
|
+
}
|
|
1053
|
+
const objectUrl = video.dataset?.bcObjectUrl;
|
|
1054
|
+
if (
|
|
1055
|
+
objectUrl &&
|
|
1056
|
+
typeof URL !== "undefined" &&
|
|
1057
|
+
typeof URL.revokeObjectURL === "function"
|
|
1058
|
+
) {
|
|
1059
|
+
URL.revokeObjectURL(objectUrl);
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
video.removeAttribute("src");
|
|
1063
|
+
video.srcObject = null;
|
|
1064
|
+
video.load();
|
|
1065
|
+
} catch {
|
|
1066
|
+
/* detached or already released */
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function disposeSlot(slot) {
|
|
1071
|
+
if (!slot) return;
|
|
1072
|
+
for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
|
|
1073
|
+
for (const image of slot.querySelectorAll?.("img") ?? []) {
|
|
1074
|
+
releaseImage(image);
|
|
1075
|
+
}
|
|
1076
|
+
slot.remove?.();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function createSlot(payload, video = null) {
|
|
1080
|
+
const slot = document.createElement("div");
|
|
1081
|
+
slot.className = "beauticode-media-slot";
|
|
1082
|
+
slot.dataset.bcRole = "candidate";
|
|
1083
|
+
slot.dataset.bcMedia = payload.media;
|
|
1084
|
+
slot.dataset.bcGeneration = String(payload.generation);
|
|
1085
|
+
slot.dataset.bcImageUrl = payload.imageUrl;
|
|
1086
|
+
if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
|
|
1087
|
+
if (payload.media === "video") {
|
|
1088
|
+
const startAt = Number(payload.startAt);
|
|
1089
|
+
slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
|
|
1090
|
+
}
|
|
1091
|
+
if (video) {
|
|
1092
|
+
video.className = "beauticode-media-video";
|
|
1093
|
+
slot.append(video);
|
|
1094
|
+
}
|
|
1095
|
+
return slot;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function slotMatchesPayload(slot, payload) {
|
|
1099
|
+
if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
if (payload.media !== "video") {
|
|
1103
|
+
if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
|
|
1104
|
+
const image = slot.querySelector?.("img");
|
|
1105
|
+
return Boolean(
|
|
1106
|
+
image?.isConnected === true &&
|
|
1107
|
+
image.complete &&
|
|
1108
|
+
image.naturalWidth > 0 &&
|
|
1109
|
+
image.naturalHeight > 0,
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
|
|
1113
|
+
const video = slot.querySelector?.("video");
|
|
1114
|
+
if (
|
|
1115
|
+
!(video instanceof HTMLVideoElement) ||
|
|
1116
|
+
video.error ||
|
|
1117
|
+
video.ended ||
|
|
1118
|
+
video.paused ||
|
|
1119
|
+
video.seeking ||
|
|
1120
|
+
video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
|
1121
|
+
) {
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
1124
|
+
// A local re-import can keep the exact same video handle while producing a
|
|
1125
|
+
// new poster token and resetting startAt. Reuse the live decoder; the
|
|
1126
|
+
// caller updates the poster metadata and seeks in place when needed.
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function updateCommittedDom(payload, videoReady = true) {
|
|
1131
|
+
document.documentElement.dataset.bcGeneration = String(payload.generation);
|
|
1132
|
+
document.documentElement.removeAttribute("data-bc-pending-generation");
|
|
1133
|
+
if (payload.media === "clear") {
|
|
1134
|
+
document.documentElement.removeAttribute("data-bc-active");
|
|
1135
|
+
document.documentElement.removeAttribute("data-bc-media");
|
|
1136
|
+
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
document.documentElement.dataset.bcActive = "true";
|
|
1140
|
+
document.documentElement.dataset.bcMedia = payload.media;
|
|
1141
|
+
if (payload.media === "video") {
|
|
1142
|
+
// "false" keeps the poster as the committed visual until the settle
|
|
1143
|
+
// phase flips it after the first presented frame.
|
|
1144
|
+
document.documentElement.dataset.bcVideoReady = videoReady ? "true" : "false";
|
|
1145
|
+
} else {
|
|
1146
|
+
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function discardCandidates() {
|
|
1151
|
+
const node = document.getElementById("beauticode-bg-stage");
|
|
1152
|
+
if (!node) return;
|
|
1153
|
+
node.removeAttribute("data-bc-transitioning");
|
|
1154
|
+
node.removeAttribute("data-bc-empty");
|
|
1155
|
+
for (const slot of
|
|
1156
|
+
node.querySelectorAll?.('.beauticode-media-slot[data-bc-role="candidate"]') ?? []) {
|
|
1157
|
+
disposeSlot(slot);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function attachCandidate(slot, payload) {
|
|
1162
|
+
const node = stage();
|
|
1163
|
+
discardCandidates();
|
|
1164
|
+
const previous = mountedCurrentSlot();
|
|
1165
|
+
slot.dataset.bcRole = "candidate";
|
|
1166
|
+
node.append(slot);
|
|
1167
|
+
if (!previous) {
|
|
1168
|
+
node.dataset.bcEmpty = "true";
|
|
1169
|
+
document.documentElement.dataset.bcActive = "true";
|
|
1170
|
+
document.documentElement.dataset.bcMedia =
|
|
1171
|
+
payload.media === "video" ? "video-pending" : payload.media;
|
|
1172
|
+
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function nextFrame(signal) {
|
|
1177
|
+
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
|
|
1178
|
+
return new Promise((resolve, reject) => {
|
|
1179
|
+
let settled = false;
|
|
1180
|
+
let request = null;
|
|
1181
|
+
let timer = null;
|
|
1182
|
+
const cleanup = () => {
|
|
1183
|
+
if (timer) clearTimeout(timer);
|
|
1184
|
+
if (request != null && typeof cancelAnimationFrame === "function") {
|
|
1185
|
+
cancelAnimationFrame(request);
|
|
1186
|
+
}
|
|
1187
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
1188
|
+
};
|
|
1189
|
+
const finish = (error = null) => {
|
|
1190
|
+
if (settled) return;
|
|
1191
|
+
settled = true;
|
|
1192
|
+
cleanup();
|
|
1193
|
+
if (error) reject(error);
|
|
1194
|
+
else resolve();
|
|
1195
|
+
};
|
|
1196
|
+
const aborted = () => {
|
|
1197
|
+
finish(abortError());
|
|
1198
|
+
};
|
|
1199
|
+
request = requestAnimationFrame(() => {
|
|
1200
|
+
request = null;
|
|
1201
|
+
if (signal?.aborted) finish(abortError());
|
|
1202
|
+
else finish();
|
|
1203
|
+
});
|
|
1204
|
+
// Background or power-saved Chromium pages may suspend rAF entirely.
|
|
1205
|
+
// A short timer keeps the transaction bounded without skipping aborts.
|
|
1206
|
+
timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
|
|
1207
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
1208
|
+
if (signal?.aborted) aborted();
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function waitForCrossfade(slot, signal) {
|
|
1213
|
+
if (reducedMotionMedia?.matches) return Promise.resolve();
|
|
1214
|
+
return new Promise((resolve, reject) => {
|
|
1215
|
+
let settled = false;
|
|
1216
|
+
const cleanup = () => {
|
|
1217
|
+
clearTimeout(timer);
|
|
1218
|
+
slot.removeEventListener("transitionend", ended);
|
|
1219
|
+
signal?.removeEventListener?.("abort", aborted);
|
|
1220
|
+
};
|
|
1221
|
+
const finish = (error) => {
|
|
1222
|
+
if (settled) return;
|
|
1223
|
+
settled = true;
|
|
1224
|
+
cleanup();
|
|
1225
|
+
if (error) reject(error);
|
|
1226
|
+
else resolve();
|
|
1227
|
+
};
|
|
1228
|
+
const ended = (event) => {
|
|
1229
|
+
if (event.target === slot && (!event.propertyName || event.propertyName === "opacity")) finish();
|
|
1230
|
+
};
|
|
1231
|
+
const aborted = () => finish(abortError());
|
|
1232
|
+
const timer = setTimeout(() => finish(), CROSSFADE_MS + 80);
|
|
1233
|
+
slot.addEventListener("transitionend", ended);
|
|
1234
|
+
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
1235
|
+
if (signal?.aborted) aborted();
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
|
|
1240
|
+
throwIfAborted(signal);
|
|
1241
|
+
const node = stage();
|
|
1242
|
+
const previous = mountedCurrentSlot();
|
|
1243
|
+
const canAnimate =
|
|
1244
|
+
previous &&
|
|
1245
|
+
reducedMotionMedia?.matches !== true &&
|
|
1246
|
+
document.visibilityState === "visible" &&
|
|
1247
|
+
remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
|
|
1248
|
+
if (canAnimate) {
|
|
1249
|
+
await nextFrame(signal);
|
|
1250
|
+
await nextFrame(signal);
|
|
1251
|
+
throwIfAborted(signal);
|
|
1252
|
+
node.dataset.bcTransitioning = "true";
|
|
1253
|
+
await waitForCrossfade(previous, signal);
|
|
1254
|
+
}
|
|
1255
|
+
throwIfAborted(signal);
|
|
1256
|
+
if (previous && previous !== slot) disposeSlot(previous);
|
|
1257
|
+
slot.dataset.bcRole = "current";
|
|
1258
|
+
node.removeAttribute("data-bc-transitioning");
|
|
1259
|
+
node.removeAttribute("data-bc-empty");
|
|
1260
|
+
currentSlot = slot;
|
|
1261
|
+
committedPayload = payload;
|
|
1262
|
+
const video = activeVideo();
|
|
1263
|
+
playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true";
|
|
1264
|
+
updateCommittedDom(payload, slot.dataset.bcVideoReady === "true");
|
|
1265
|
+
syncGallery(payload);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function restoreCommittedDom() {
|
|
1269
|
+
const node = document.getElementById("beauticode-bg-stage");
|
|
1270
|
+
node?.removeAttribute("data-bc-transitioning");
|
|
1271
|
+
node?.removeAttribute("data-bc-empty");
|
|
1272
|
+
if (committedPayload && mountedCurrentSlot()) {
|
|
1273
|
+
updateCommittedDom(committedPayload, currentSlot.dataset.bcVideoReady === "true");
|
|
1274
|
+
playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
node?.remove();
|
|
1278
|
+
document.documentElement.removeAttribute("data-bc-active");
|
|
1279
|
+
document.documentElement.removeAttribute("data-bc-media");
|
|
1280
|
+
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function syncGallery(payload) {
|
|
1284
|
+
const on = payload?.atmosphere?.preset === "gallery";
|
|
1285
|
+
try {
|
|
1286
|
+
const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
|
|
1287
|
+
if (sync && typeof sync.then === "function") void sync.catch(() => {});
|
|
871
1288
|
} catch {
|
|
872
|
-
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
async function startCandidatePlayback(video, signal, timeoutMs) {
|
|
897
|
-
const playbackController = new AbortController();
|
|
898
|
-
const parentAborted = () => playbackController.abort();
|
|
899
|
-
signal?.addEventListener?.("abort", parentAborted, { once: true });
|
|
900
|
-
let timer = null;
|
|
901
|
-
let abortListener = null;
|
|
902
|
-
const playback = playWithPreference(video, playbackController.signal);
|
|
903
|
-
const deadline = new Promise((_, reject) => {
|
|
904
|
-
timer = setTimeout(() => {
|
|
905
|
-
reject(new Error(describeVideoState(video, "等待视频开始播放超时")));
|
|
906
|
-
playbackController.abort();
|
|
907
|
-
}, timeoutMs);
|
|
908
|
-
abortListener = () => reject(abortError());
|
|
909
|
-
playbackController.signal.addEventListener("abort", abortListener, { once: true });
|
|
910
|
-
});
|
|
911
|
-
if (signal?.aborted) parentAborted();
|
|
912
|
-
try {
|
|
913
|
-
// The play() promise itself resolves only after playback has started, so
|
|
914
|
-
// a second playing-event waiter would create an orphaned promise race.
|
|
915
|
-
return await Promise.race([playback, deadline]);
|
|
916
|
-
} catch (error) {
|
|
917
|
-
playbackController.abort();
|
|
918
|
-
try {
|
|
919
|
-
video.pause();
|
|
920
|
-
} catch {
|
|
921
|
-
/* best effort: disposeSlot will release src as the final guard */
|
|
922
|
-
}
|
|
923
|
-
throw error;
|
|
924
|
-
} finally {
|
|
925
|
-
if (timer) clearTimeout(timer);
|
|
926
|
-
signal?.removeEventListener?.("abort", parentAborted);
|
|
927
|
-
if (abortListener) {
|
|
928
|
-
playbackController.signal.removeEventListener("abort", abortListener);
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function disposeVideo(video) {
|
|
934
|
-
if (!(video instanceof HTMLVideoElement)) return;
|
|
935
|
-
try {
|
|
936
|
-
video.pause();
|
|
937
|
-
} catch {
|
|
938
|
-
/* best effort */
|
|
939
|
-
}
|
|
940
|
-
const objectUrl = video.dataset?.bcObjectUrl;
|
|
941
|
-
if (
|
|
942
|
-
objectUrl &&
|
|
943
|
-
typeof URL !== "undefined" &&
|
|
944
|
-
typeof URL.revokeObjectURL === "function"
|
|
945
|
-
) {
|
|
946
|
-
URL.revokeObjectURL(objectUrl);
|
|
947
|
-
}
|
|
948
|
-
try {
|
|
949
|
-
video.removeAttribute("src");
|
|
950
|
-
video.srcObject = null;
|
|
951
|
-
video.load();
|
|
952
|
-
} catch {
|
|
953
|
-
/* detached or already released */
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
function disposeSlot(slot) {
|
|
958
|
-
if (!slot) return;
|
|
959
|
-
for (const video of slot.querySelectorAll?.("video") ?? []) disposeVideo(video);
|
|
960
|
-
for (const image of slot.querySelectorAll?.("img") ?? []) {
|
|
961
|
-
releaseImage(image);
|
|
962
|
-
}
|
|
963
|
-
slot.remove?.();
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
function createSlot(payload, video = null) {
|
|
967
|
-
const slot = document.createElement("div");
|
|
968
|
-
slot.className = "beauticode-media-slot";
|
|
969
|
-
slot.dataset.bcRole = "candidate";
|
|
970
|
-
slot.dataset.bcMedia = payload.media;
|
|
971
|
-
slot.dataset.bcGeneration = String(payload.generation);
|
|
972
|
-
slot.dataset.bcImageUrl = payload.imageUrl;
|
|
973
|
-
if (payload.videoUrl) slot.dataset.bcVideoUrl = payload.videoUrl;
|
|
974
|
-
if (payload.media === "video") {
|
|
975
|
-
const startAt = Number(payload.startAt);
|
|
976
|
-
slot.dataset.bcStartAt = String(Number.isFinite(startAt) && startAt >= 0 ? startAt : 0);
|
|
977
|
-
}
|
|
978
|
-
if (video) {
|
|
979
|
-
video.className = "beauticode-media-video";
|
|
980
|
-
slot.append(video);
|
|
981
|
-
}
|
|
982
|
-
return slot;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
function slotMatchesPayload(slot, payload) {
|
|
986
|
-
if (!slot || slot !== mountedCurrentSlot() || slot.dataset.bcMedia !== payload.media) {
|
|
987
|
-
return false;
|
|
988
|
-
}
|
|
989
|
-
if (payload.media !== "video") {
|
|
990
|
-
if (slot.dataset.bcImageUrl !== payload.imageUrl) return false;
|
|
991
|
-
const image = slot.querySelector?.("img");
|
|
992
|
-
return Boolean(
|
|
993
|
-
image?.isConnected === true &&
|
|
994
|
-
image.complete &&
|
|
995
|
-
image.naturalWidth > 0 &&
|
|
996
|
-
image.naturalHeight > 0,
|
|
997
|
-
);
|
|
998
|
-
}
|
|
999
|
-
if (slot.dataset.bcVideoUrl !== payload.videoUrl) return false;
|
|
1000
|
-
const video = slot.querySelector?.("video");
|
|
1001
|
-
if (
|
|
1002
|
-
!(video instanceof HTMLVideoElement) ||
|
|
1003
|
-
video.error ||
|
|
1004
|
-
video.ended ||
|
|
1005
|
-
video.paused ||
|
|
1006
|
-
video.seeking ||
|
|
1007
|
-
video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA
|
|
1008
|
-
) {
|
|
1009
|
-
return false;
|
|
1010
|
-
}
|
|
1011
|
-
// A local re-import can keep the exact same video handle while producing a
|
|
1012
|
-
// new poster token and resetting startAt. Reuse the live decoder; the
|
|
1013
|
-
// caller updates the poster metadata and seeks in place when needed.
|
|
1014
|
-
return true;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function updateCommittedDom(payload) {
|
|
1018
|
-
document.documentElement.dataset.bcGeneration = String(payload.generation);
|
|
1019
|
-
document.documentElement.removeAttribute("data-bc-pending-generation");
|
|
1020
|
-
if (payload.media === "clear") {
|
|
1021
|
-
document.documentElement.removeAttribute("data-bc-active");
|
|
1022
|
-
document.documentElement.removeAttribute("data-bc-media");
|
|
1023
|
-
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1024
|
-
return;
|
|
1025
|
-
}
|
|
1026
|
-
document.documentElement.dataset.bcActive = "true";
|
|
1027
|
-
document.documentElement.dataset.bcMedia = payload.media;
|
|
1028
|
-
if (payload.media === "video") document.documentElement.dataset.bcVideoReady = "true";
|
|
1029
|
-
else document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
function discardCandidates() {
|
|
1033
|
-
const node = document.getElementById("beauticode-bg-stage");
|
|
1034
|
-
if (!node) return;
|
|
1035
|
-
node.removeAttribute("data-bc-transitioning");
|
|
1036
|
-
node.removeAttribute("data-bc-empty");
|
|
1037
|
-
for (const slot of
|
|
1038
|
-
node.querySelectorAll?.('.beauticode-media-slot[data-bc-role="candidate"]') ?? []) {
|
|
1039
|
-
disposeSlot(slot);
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
function attachCandidate(slot, payload) {
|
|
1044
|
-
const node = stage();
|
|
1045
|
-
discardCandidates();
|
|
1046
|
-
const previous = mountedCurrentSlot();
|
|
1047
|
-
slot.dataset.bcRole = "candidate";
|
|
1048
|
-
node.append(slot);
|
|
1049
|
-
if (!previous) {
|
|
1050
|
-
node.dataset.bcEmpty = "true";
|
|
1051
|
-
document.documentElement.dataset.bcActive = "true";
|
|
1052
|
-
document.documentElement.dataset.bcMedia =
|
|
1053
|
-
payload.media === "video" ? "video-pending" : payload.media;
|
|
1054
|
-
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
function nextFrame(signal) {
|
|
1059
|
-
if (typeof requestAnimationFrame !== "function") return Promise.resolve();
|
|
1060
|
-
return new Promise((resolve, reject) => {
|
|
1061
|
-
let settled = false;
|
|
1062
|
-
let request = null;
|
|
1063
|
-
let timer = null;
|
|
1064
|
-
const cleanup = () => {
|
|
1065
|
-
if (timer) clearTimeout(timer);
|
|
1066
|
-
if (request != null && typeof cancelAnimationFrame === "function") {
|
|
1067
|
-
cancelAnimationFrame(request);
|
|
1068
|
-
}
|
|
1069
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
1070
|
-
};
|
|
1071
|
-
const finish = (error = null) => {
|
|
1072
|
-
if (settled) return;
|
|
1073
|
-
settled = true;
|
|
1074
|
-
cleanup();
|
|
1075
|
-
if (error) reject(error);
|
|
1076
|
-
else resolve();
|
|
1077
|
-
};
|
|
1078
|
-
const aborted = () => {
|
|
1079
|
-
finish(abortError());
|
|
1080
|
-
};
|
|
1081
|
-
request = requestAnimationFrame(() => {
|
|
1082
|
-
request = null;
|
|
1083
|
-
if (signal?.aborted) finish(abortError());
|
|
1084
|
-
else finish();
|
|
1085
|
-
});
|
|
1086
|
-
// Background or power-saved Chromium pages may suspend rAF entirely.
|
|
1087
|
-
// A short timer keeps the transaction bounded without skipping aborts.
|
|
1088
|
-
timer = setTimeout(() => finish(), FRAME_FALLBACK_MS);
|
|
1089
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
1090
|
-
if (signal?.aborted) aborted();
|
|
1091
|
-
});
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
function waitForCrossfade(slot, signal) {
|
|
1095
|
-
if (reducedMotionMedia?.matches) return Promise.resolve();
|
|
1096
|
-
return new Promise((resolve, reject) => {
|
|
1097
|
-
let settled = false;
|
|
1098
|
-
const cleanup = () => {
|
|
1099
|
-
clearTimeout(timer);
|
|
1100
|
-
slot.removeEventListener("transitionend", ended);
|
|
1101
|
-
signal?.removeEventListener?.("abort", aborted);
|
|
1102
|
-
};
|
|
1103
|
-
const finish = (error) => {
|
|
1104
|
-
if (settled) return;
|
|
1105
|
-
settled = true;
|
|
1106
|
-
cleanup();
|
|
1107
|
-
if (error) reject(error);
|
|
1108
|
-
else resolve();
|
|
1109
|
-
};
|
|
1110
|
-
const ended = (event) => {
|
|
1111
|
-
if (event.target === slot && (!event.propertyName || event.propertyName === "opacity")) finish();
|
|
1112
|
-
};
|
|
1113
|
-
const aborted = () => finish(abortError());
|
|
1114
|
-
const timer = setTimeout(() => finish(), CROSSFADE_MS + 80);
|
|
1115
|
-
slot.addEventListener("transitionend", ended);
|
|
1116
|
-
signal?.addEventListener?.("abort", aborted, { once: true });
|
|
1117
|
-
if (signal?.aborted) aborted();
|
|
1118
|
-
});
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
async function commitCandidate(payload, slot, signal, remainingMs = () => Infinity) {
|
|
1122
|
-
throwIfAborted(signal);
|
|
1123
|
-
const node = stage();
|
|
1124
|
-
const previous = mountedCurrentSlot();
|
|
1125
|
-
const canAnimate =
|
|
1126
|
-
previous &&
|
|
1127
|
-
reducedMotionMedia?.matches !== true &&
|
|
1128
|
-
document.visibilityState === "visible" &&
|
|
1129
|
-
remainingMs() > CROSSFADE_MS + FRAME_FALLBACK_MS * 2 + 250;
|
|
1130
|
-
if (canAnimate) {
|
|
1131
|
-
await nextFrame(signal);
|
|
1132
|
-
await nextFrame(signal);
|
|
1133
|
-
throwIfAborted(signal);
|
|
1134
|
-
node.dataset.bcTransitioning = "true";
|
|
1135
|
-
await waitForCrossfade(previous, signal);
|
|
1136
|
-
}
|
|
1137
|
-
throwIfAborted(signal);
|
|
1138
|
-
if (previous && previous !== slot) disposeSlot(previous);
|
|
1139
|
-
slot.dataset.bcRole = "current";
|
|
1140
|
-
node.removeAttribute("data-bc-transitioning");
|
|
1141
|
-
node.removeAttribute("data-bc-empty");
|
|
1142
|
-
currentSlot = slot;
|
|
1143
|
-
committedPayload = payload;
|
|
1144
|
-
const video = activeVideo();
|
|
1145
|
-
playbackBlocked = video?.dataset?.bcPlaybackBlocked === "true";
|
|
1146
|
-
updateCommittedDom(payload);
|
|
1147
|
-
syncGallery(payload);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
function restoreCommittedDom() {
|
|
1151
|
-
const node = document.getElementById("beauticode-bg-stage");
|
|
1152
|
-
node?.removeAttribute("data-bc-transitioning");
|
|
1153
|
-
node?.removeAttribute("data-bc-empty");
|
|
1154
|
-
if (committedPayload && mountedCurrentSlot()) {
|
|
1155
|
-
updateCommittedDom(committedPayload);
|
|
1156
|
-
playbackBlocked = activeVideo()?.dataset?.bcPlaybackBlocked === "true";
|
|
1157
|
-
return;
|
|
1158
|
-
}
|
|
1159
|
-
node?.remove();
|
|
1160
|
-
document.documentElement.removeAttribute("data-bc-active");
|
|
1161
|
-
document.documentElement.removeAttribute("data-bc-media");
|
|
1162
|
-
document.documentElement.removeAttribute("data-bc-video-ready");
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
function syncGallery(payload) {
|
|
1166
|
-
const on = payload?.atmosphere?.preset === "gallery";
|
|
1167
|
-
try {
|
|
1168
|
-
const sync = globalThis.BeauticodeAtmosphere?.setWindowMode?.(on ? "on" : "closed");
|
|
1169
|
-
if (sync && typeof sync.then === "function") void sync.catch(() => {});
|
|
1170
|
-
} catch {
|
|
1171
|
-
/* Atmosphere is optional and must never invalidate a rendered background. */
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
async function applyBackground(payload, signal) {
|
|
1176
|
-
throwIfAborted(signal);
|
|
1177
|
-
const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
|
|
1178
|
-
const remaining = () => Math.max(1, applyDeadline - performance.now());
|
|
1179
|
-
if (payload.media === "clear") {
|
|
1180
|
-
desiredModes.fish = false;
|
|
1181
|
-
playbackBlocked = false;
|
|
1182
|
-
document.documentElement.removeAttribute("data-bc-fish");
|
|
1183
|
-
discardCandidates();
|
|
1184
|
-
disposeSlot(currentSlot);
|
|
1185
|
-
currentSlot = null;
|
|
1186
|
-
committedPayload = payload;
|
|
1187
|
-
document.getElementById("beauticode-bg-stage")?.remove();
|
|
1188
|
-
updateCommittedDom(payload);
|
|
1189
|
-
syncGallery(payload);
|
|
1190
|
-
if (document.documentElement.dataset.bcGallery === "true") {
|
|
1191
|
-
document.documentElement.dataset.bcActive = "true";
|
|
1192
|
-
}
|
|
1289
|
+
/* Atmosphere is optional and must never invalidate a rendered background. */
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function applyBackground(payload, signal) {
|
|
1294
|
+
throwIfAborted(signal);
|
|
1295
|
+
const applyDeadline = performance.now() + CLIENT_APPLY_DEADLINE_MS;
|
|
1296
|
+
const remaining = () => Math.max(1, applyDeadline - performance.now());
|
|
1297
|
+
if (payload.media === "clear") {
|
|
1298
|
+
desiredModes.fish = false;
|
|
1299
|
+
playbackBlocked = false;
|
|
1300
|
+
document.documentElement.removeAttribute("data-bc-fish");
|
|
1301
|
+
discardCandidates();
|
|
1302
|
+
disposeSlot(currentSlot);
|
|
1303
|
+
currentSlot = null;
|
|
1304
|
+
committedPayload = payload;
|
|
1305
|
+
document.getElementById("beauticode-bg-stage")?.remove();
|
|
1306
|
+
updateCommittedDom(payload);
|
|
1307
|
+
syncGallery(payload);
|
|
1308
|
+
if (document.documentElement.dataset.bcGallery === "true") {
|
|
1309
|
+
document.documentElement.dataset.bcActive = "true";
|
|
1310
|
+
}
|
|
1193
1311
|
await acknowledgeRender(payload, true, false);
|
|
1194
1312
|
await acknowledgeMode();
|
|
1195
1313
|
return;
|
|
1196
1314
|
}
|
|
1197
|
-
if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
|
|
1198
|
-
await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
let reusable = slotMatchesPayload(currentSlot, payload);
|
|
1202
|
-
if (reusable && payload.media === "video") {
|
|
1203
|
-
try {
|
|
1204
|
-
const reusableVideo = activeVideo();
|
|
1205
|
-
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
await
|
|
1217
|
-
await
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
currentSlot.dataset.
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
await
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
let
|
|
1239
|
-
let
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
await
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
video
|
|
1259
|
-
video
|
|
1260
|
-
video.
|
|
1261
|
-
video.
|
|
1262
|
-
video.
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
video.
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
video
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
//
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
startCandidatePlayback(video, signal, remaining()),
|
|
1290
|
-
]);
|
|
1291
|
-
throwIfAborted(signal);
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
await
|
|
1295
|
-
await
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
committed
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
await
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1315
|
+
if (typeof payload.imageUrl !== "string" || payload.imageUrl.length === 0) {
|
|
1316
|
+
await acknowledgeRender(payload, false, Boolean(mountedCurrentSlot()), "图片载荷无效");
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
let reusable = slotMatchesPayload(currentSlot, payload);
|
|
1320
|
+
if (reusable && payload.media === "video") {
|
|
1321
|
+
try {
|
|
1322
|
+
const reusableVideo = activeVideo();
|
|
1323
|
+
trackVideoDiagnostics(reusableVideo);
|
|
1324
|
+
const requestedStartAt = Number(payload.startAt);
|
|
1325
|
+
const normalizedStartAt =
|
|
1326
|
+
Number.isFinite(requestedStartAt) && requestedStartAt >= 0 ? requestedStartAt : 0;
|
|
1327
|
+
const appliedStartAt = Number(currentSlot.dataset.bcStartAt);
|
|
1328
|
+
if (
|
|
1329
|
+
!Number.isFinite(appliedStartAt) ||
|
|
1330
|
+
Math.abs(appliedStartAt - normalizedStartAt) >= 0.25
|
|
1331
|
+
) {
|
|
1332
|
+
const nextStartAt = seekVideo(reusableVideo, normalizedStartAt);
|
|
1333
|
+
currentSlot.dataset.bcStartAt = String(nextStartAt);
|
|
1334
|
+
await waitForSeek(reusableVideo, signal, remaining());
|
|
1335
|
+
await waitForVideo(reusableVideo, signal, remaining());
|
|
1336
|
+
await waitForPresentedFrame(reusableVideo, signal, remaining());
|
|
1337
|
+
} else {
|
|
1338
|
+
await waitForStablePlayback(reusableVideo, signal, Math.min(750, remaining()));
|
|
1339
|
+
}
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
if (isAbortError(error) || signal?.aborted) throw error;
|
|
1342
|
+
reusable = false;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
if (reusable) {
|
|
1346
|
+
committedPayload = payload;
|
|
1347
|
+
currentSlot.dataset.bcGeneration = String(payload.generation);
|
|
1348
|
+
currentSlot.dataset.bcImageUrl = payload.imageUrl;
|
|
1349
|
+
if (payload.videoUrl) currentSlot.dataset.bcVideoUrl = payload.videoUrl;
|
|
1350
|
+
updateCommittedDom(payload);
|
|
1351
|
+
syncGallery(payload);
|
|
1352
|
+
await acknowledgeRender(payload, true, true);
|
|
1353
|
+
await acknowledgeMode();
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
let candidate = null;
|
|
1357
|
+
let candidateVideo = null;
|
|
1358
|
+
let committed = false;
|
|
1359
|
+
try {
|
|
1360
|
+
await waitForDshStructure(signal, remaining());
|
|
1361
|
+
if (payload.media === "image") {
|
|
1362
|
+
candidate = createSlot(payload);
|
|
1363
|
+
attachCandidate(candidate, payload);
|
|
1364
|
+
const image = await loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1365
|
+
throwIfAborted(signal);
|
|
1366
|
+
if (!(image.naturalWidth > 0 && image.naturalHeight > 0)) throw new Error("图片尺寸无效");
|
|
1367
|
+
await commitCandidate(payload, candidate, signal, remaining);
|
|
1368
|
+
committed = true;
|
|
1369
|
+
await acknowledgeRender(payload, true, true);
|
|
1370
|
+
await acknowledgeMode();
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (payload.media !== "video" || typeof payload.videoUrl !== "string") {
|
|
1374
|
+
throw new Error("视频载荷无效");
|
|
1375
|
+
}
|
|
1376
|
+
const video = document.createElement("video");
|
|
1377
|
+
trackVideoDiagnostics(video);
|
|
1378
|
+
video.autoplay = true;
|
|
1379
|
+
video.loop = true;
|
|
1380
|
+
video.playsInline = true;
|
|
1381
|
+
video.preload = "auto";
|
|
1382
|
+
video.crossOrigin = "anonymous";
|
|
1383
|
+
// Set mute before src so Chromium may start the request immediately
|
|
1384
|
+
// without waiting for an audible autoplay decision.
|
|
1385
|
+
video.defaultMuted = true;
|
|
1386
|
+
video.muted = desiredModes.muted;
|
|
1387
|
+
video.setAttribute("muted", "");
|
|
1388
|
+
candidate = createSlot(payload, video);
|
|
1389
|
+
attachCandidate(candidate, payload);
|
|
1390
|
+
candidateVideo = video;
|
|
1391
|
+
video.src = payload.videoUrl;
|
|
1392
|
+
video.load();
|
|
1393
|
+
// Phase one gates only what the poster commit needs: the media source
|
|
1394
|
+
// answers Range requests, the poster decodes, and playback was
|
|
1395
|
+
// initiated. A cold decoder no longer fails the transaction here — the
|
|
1396
|
+
// video upgrades from the committed poster in the settle phase, so a
|
|
1397
|
+
// fresh machine that merely loads slowly can never time out.
|
|
1398
|
+
const sourceProbe = probeVideoSource(
|
|
1399
|
+
payload.videoUrl,
|
|
1400
|
+
signal,
|
|
1401
|
+
Math.min(VIDEO_PROBE_TIMEOUT_MS, remaining()),
|
|
1402
|
+
);
|
|
1403
|
+
const imageReady = loadImage(candidate, payload.imageUrl, signal, remaining());
|
|
1404
|
+
await Promise.all([
|
|
1405
|
+
sourceProbe,
|
|
1406
|
+
imageReady,
|
|
1407
|
+
startCandidatePlayback(video, signal, Math.min(PLAY_ACCEPT_TIMEOUT_MS, remaining())),
|
|
1408
|
+
]);
|
|
1409
|
+
throwIfAborted(signal);
|
|
1410
|
+
await commitCandidate(payload, candidate, signal, remaining);
|
|
1411
|
+
committed = true;
|
|
1412
|
+
await acknowledgeRender(payload, true, true, null, { videoReady: false });
|
|
1413
|
+
await acknowledgeMode();
|
|
1414
|
+
settleCommittedVideo(video, candidate, payload);
|
|
1415
|
+
return;
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
if (candidateVideo && candidateVideo.parentElement !== candidate) {
|
|
1418
|
+
disposeVideo(candidateVideo);
|
|
1419
|
+
candidateVideo.remove?.();
|
|
1420
|
+
}
|
|
1421
|
+
if (candidate && !committed) disposeSlot(candidate);
|
|
1422
|
+
if (isAbortError(error) || signal?.aborted || activePayload !== payload) return;
|
|
1423
|
+
restoreCommittedDom();
|
|
1424
|
+
await acknowledgeRender(
|
|
1425
|
+
payload,
|
|
1426
|
+
false,
|
|
1427
|
+
Boolean(currentSlot),
|
|
1428
|
+
error instanceof Error ? error.message : String(error),
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Phase two of a video apply. The poster is already committed and acked, so
|
|
1434
|
+
// nothing here can roll the transaction back: a decoder that needs longer
|
|
1435
|
+
// than any host deadline simply keeps the poster until the first frame
|
|
1436
|
+
// presents, then upgrades the committed slot in place without a flash.
|
|
1437
|
+
function settleCommittedVideo(video, slot, payload) {
|
|
1438
|
+
videoSettleController?.abort();
|
|
1439
|
+
const controller = new AbortController();
|
|
1440
|
+
videoSettleController = controller;
|
|
1441
|
+
const stillSettling = () => !controller.signal.aborted && mountedCurrentSlot() === slot;
|
|
1442
|
+
void (async () => {
|
|
1443
|
+
try {
|
|
1444
|
+
await waitForVideo(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1445
|
+
throwIfAborted(controller.signal);
|
|
1446
|
+
if (!stillSettling()) return;
|
|
1447
|
+
const appliedStartAt = seekVideo(video, payload.startAt);
|
|
1448
|
+
slot.dataset.bcStartAt = String(appliedStartAt);
|
|
1449
|
+
await waitForSeek(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1450
|
+
await waitForVideo(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1451
|
+
await waitForPresentedFrame(video, controller.signal, VIDEO_SETTLE_TIMEOUT_MS);
|
|
1452
|
+
if (!stillSettling()) return;
|
|
1453
|
+
slot.dataset.bcVideoReady = "true";
|
|
1454
|
+
updateCommittedDom(payload, true);
|
|
1455
|
+
playbackBlocked = video.dataset?.bcPlaybackBlocked === "true";
|
|
1456
|
+
await acknowledgeRender(payload, true, true, null, { videoReady: true });
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
if (isAbortError(error) || controller.signal.aborted || !stillSettling()) return;
|
|
1459
|
+
// Keep the committed poster; drop the stalled decoder so a later apply
|
|
1460
|
+
// or re-import starts from a clean media element.
|
|
1461
|
+
disposeVideo(video);
|
|
1462
|
+
video.remove?.();
|
|
1463
|
+
slot.removeAttribute("data-bc-video-ready");
|
|
1464
|
+
await acknowledgeRender(
|
|
1465
|
+
payload,
|
|
1466
|
+
true,
|
|
1467
|
+
true,
|
|
1468
|
+
`视频预热未完成(已保留封面):${error instanceof Error ? error.message : String(error)}`,
|
|
1469
|
+
{ videoReady: false },
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
})();
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function scheduleBackground(payload) {
|
|
1476
|
+
if (!Number.isSafeInteger(payload?.generation)) return;
|
|
1477
|
+
// EventSource reconnects can replay the current frame while its first
|
|
1478
|
+
// render is still in flight. Restarting the same generation would discard
|
|
1479
|
+
// a healthy cold media request and move its deadline indefinitely.
|
|
1480
|
+
if (
|
|
1481
|
+
applyController &&
|
|
1482
|
+
activePayload?.generation === payload.generation &&
|
|
1483
|
+
activePayload?.media === payload.media
|
|
1484
|
+
) {
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (
|
|
1488
|
+
Number.isSafeInteger(activePayload?.generation) &&
|
|
1489
|
+
payload.generation < activePayload.generation
|
|
1490
|
+
) {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
applyController?.abort();
|
|
1494
|
+
videoSettleController?.abort();
|
|
1495
|
+
videoSettleController = null;
|
|
1496
|
+
discardCandidates();
|
|
1497
|
+
const controller = new AbortController();
|
|
1498
|
+
applyController = controller;
|
|
1499
|
+
activePayload = payload;
|
|
1500
|
+
renderPhase = "pending";
|
|
1501
|
+
document.documentElement.dataset.bcPendingGeneration = String(payload.generation);
|
|
1502
|
+
void applyBackground(payload, controller.signal)
|
|
1503
|
+
.catch(async (error) => {
|
|
1504
|
+
if (isAbortError(error) || controller.signal.aborted || activePayload !== payload) return;
|
|
1505
|
+
restoreCommittedDom();
|
|
1506
|
+
await acknowledgeRender(
|
|
1507
|
+
payload,
|
|
1508
|
+
false,
|
|
1509
|
+
Boolean(mountedCurrentSlot()),
|
|
1510
|
+
error instanceof Error ? error.message : String(error),
|
|
1511
|
+
);
|
|
1512
|
+
})
|
|
1513
|
+
.finally(() => {
|
|
1514
|
+
if (applyController !== controller) return;
|
|
1515
|
+
applyController = null;
|
|
1516
|
+
document.documentElement.removeAttribute("data-bc-pending-generation");
|
|
1517
|
+
})
|
|
1518
|
+
.catch(() => {});
|
|
1519
|
+
}
|
|
1367
1520
|
|
|
1368
1521
|
async function applyModes(payload) {
|
|
1369
1522
|
if (typeof payload.fish === "boolean") desiredModes.fish = payload.fish;
|
|
@@ -1373,24 +1526,24 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1373
1526
|
syncDshTheme();
|
|
1374
1527
|
if (desiredModes.fish && document.documentElement.dataset.bcActive === "true") {
|
|
1375
1528
|
document.documentElement.dataset.bcFish = "true";
|
|
1376
|
-
} else {
|
|
1377
|
-
document.documentElement.removeAttribute("data-bc-fish");
|
|
1378
|
-
}
|
|
1379
|
-
const video = activeVideo();
|
|
1380
|
-
const candidate = pendingVideo();
|
|
1381
|
-
if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
|
|
1382
|
-
if (video instanceof HTMLVideoElement) {
|
|
1383
|
-
try {
|
|
1384
|
-
playbackBlocked = await playWithPreference(video);
|
|
1529
|
+
} else {
|
|
1530
|
+
document.documentElement.removeAttribute("data-bc-fish");
|
|
1531
|
+
}
|
|
1532
|
+
const video = activeVideo();
|
|
1533
|
+
const candidate = pendingVideo();
|
|
1534
|
+
if (candidate instanceof HTMLVideoElement) candidate.muted = desiredModes.muted;
|
|
1535
|
+
if (video instanceof HTMLVideoElement) {
|
|
1536
|
+
try {
|
|
1537
|
+
playbackBlocked = await playWithPreference(video);
|
|
1385
1538
|
} catch {
|
|
1386
1539
|
playbackBlocked = desiredModes.muted === false;
|
|
1387
1540
|
}
|
|
1388
|
-
if (
|
|
1389
|
-
committedPayload?.media === "video" &&
|
|
1390
|
-
activePayload === committedPayload &&
|
|
1391
|
-
renderPhase === "ready"
|
|
1392
|
-
) {
|
|
1393
|
-
await acknowledgeRender(committedPayload, true, true);
|
|
1541
|
+
if (
|
|
1542
|
+
committedPayload?.media === "video" &&
|
|
1543
|
+
activePayload === committedPayload &&
|
|
1544
|
+
renderPhase === "ready"
|
|
1545
|
+
) {
|
|
1546
|
+
await acknowledgeRender(committedPayload, true, true);
|
|
1394
1547
|
}
|
|
1395
1548
|
} else {
|
|
1396
1549
|
playbackBlocked = false;
|
|
@@ -1403,49 +1556,49 @@ html[data-bc-fish="true"] #root{opacity:0!important;visibility:hidden!important;
|
|
|
1403
1556
|
);
|
|
1404
1557
|
events.onmessage = (event) => {
|
|
1405
1558
|
try {
|
|
1406
|
-
const payload = JSON.parse(event.data);
|
|
1407
|
-
if (payload?.type === "mode") void applyModes(payload).catch(() => {});
|
|
1408
|
-
else if (payload?.type === "apply") scheduleBackground(payload);
|
|
1559
|
+
const payload = JSON.parse(event.data);
|
|
1560
|
+
if (payload?.type === "mode") void applyModes(payload).catch(() => {});
|
|
1561
|
+
else if (payload?.type === "apply") scheduleBackground(payload);
|
|
1409
1562
|
} catch {
|
|
1410
1563
|
/* EventSource will continue with the next valid frame. */
|
|
1411
1564
|
}
|
|
1412
1565
|
};
|
|
1413
1566
|
|
|
1414
|
-
setInterval(() => {
|
|
1415
|
-
if (
|
|
1416
|
-
!committedPayload ||
|
|
1417
|
-
activePayload !== committedPayload ||
|
|
1418
|
-
renderPhase !== "ready"
|
|
1419
|
-
) {
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
if (committedPayload.media === "clear") {
|
|
1423
|
-
void acknowledgeRender(committedPayload, true, false).catch(() => {});
|
|
1424
|
-
return;
|
|
1425
|
-
}
|
|
1426
|
-
const slot = mountedCurrentSlot();
|
|
1427
|
-
if (!slot) return;
|
|
1428
|
-
if (committedPayload.media === "image") {
|
|
1429
|
-
const image = slot.querySelector?.("img");
|
|
1430
|
-
if (
|
|
1431
|
-
image?.isConnected === true &&
|
|
1432
|
-
image.complete &&
|
|
1433
|
-
image.naturalWidth > 0 &&
|
|
1434
|
-
image.naturalHeight > 0
|
|
1435
|
-
) {
|
|
1436
|
-
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
1437
|
-
}
|
|
1438
|
-
return;
|
|
1439
|
-
}
|
|
1440
|
-
const video = activeVideo();
|
|
1441
|
-
if (
|
|
1442
|
-
committedPayload.media === "video" &&
|
|
1443
|
-
video instanceof HTMLVideoElement
|
|
1444
|
-
) {
|
|
1567
|
+
setInterval(() => {
|
|
1568
|
+
if (
|
|
1569
|
+
!committedPayload ||
|
|
1570
|
+
activePayload !== committedPayload ||
|
|
1571
|
+
renderPhase !== "ready"
|
|
1572
|
+
) {
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
if (committedPayload.media === "clear") {
|
|
1576
|
+
void acknowledgeRender(committedPayload, true, false).catch(() => {});
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
const slot = mountedCurrentSlot();
|
|
1580
|
+
if (!slot) return;
|
|
1581
|
+
if (committedPayload.media === "image") {
|
|
1582
|
+
const image = slot.querySelector?.("img");
|
|
1583
|
+
if (
|
|
1584
|
+
image?.isConnected === true &&
|
|
1585
|
+
image.complete &&
|
|
1586
|
+
image.naturalWidth > 0 &&
|
|
1587
|
+
image.naturalHeight > 0
|
|
1588
|
+
) {
|
|
1589
|
+
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
1590
|
+
}
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const video = activeVideo();
|
|
1594
|
+
if (
|
|
1595
|
+
committedPayload.media === "video" &&
|
|
1596
|
+
video instanceof HTMLVideoElement
|
|
1597
|
+
) {
|
|
1445
1598
|
// A heartbeat is observational, not a second render verdict. Playback can
|
|
1446
1599
|
// briefly pause while Chromium changes modes or refills an 8K buffer; do
|
|
1447
1600
|
// not downgrade an already-rendered generation or fail a pending one.
|
|
1448
|
-
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
1601
|
+
void acknowledgeRender(committedPayload, true, true).catch(() => {});
|
|
1449
1602
|
}
|
|
1450
1603
|
}, 1_000);
|
|
1451
1604
|
})();
|