@lingxia/elements 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/component.d.ts +50 -0
- package/dist/component.js +139 -0
- package/dist/dom.d.ts +10 -0
- package/dist/dom.js +70 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/input.d.ts +117 -0
- package/dist/input.js +699 -0
- package/dist/native_component_wrapper_shared.d.ts +74 -0
- package/dist/native_component_wrapper_shared.js +193 -0
- package/dist/nativecomponent.d.ts +6 -0
- package/dist/nativecomponent.js +82 -0
- package/dist/navigator.d.ts +83 -0
- package/dist/navigator.js +388 -0
- package/dist/picker-web.d.ts +2 -0
- package/dist/picker-web.js +727 -0
- package/dist/picker.d.ts +77 -0
- package/dist/picker.js +342 -0
- package/dist/platform.d.ts +6 -0
- package/dist/platform.js +24 -0
- package/dist/text_component_native_attrs.d.ts +53 -0
- package/dist/text_component_native_attrs.js +111 -0
- package/dist/text_component_shared.d.ts +7 -0
- package/dist/text_component_shared.js +148 -0
- package/dist/textarea.d.ts +129 -0
- package/dist/textarea.js +841 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +1 -0
- package/dist/video.d.ts +134 -0
- package/dist/video.js +741 -0
- package/package.json +39 -0
- package/src/component.ts +164 -0
- package/src/dom.ts +83 -0
- package/src/index.ts +69 -0
- package/src/input.ts +799 -0
- package/src/native_component_wrapper_shared.ts +243 -0
- package/src/nativecomponent.ts +97 -0
- package/src/navigator.ts +533 -0
- package/src/picker-web.ts +719 -0
- package/src/picker.ts +418 -0
- package/src/platform.ts +25 -0
- package/src/text_component_native_attrs.ts +137 -0
- package/src/text_component_shared.ts +147 -0
- package/src/textarea.ts +959 -0
- package/src/types.ts +20 -0
- package/src/video.ts +855 -0
package/src/video.ts
ADDED
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sendNativeComponentMessage,
|
|
3
|
+
registerNativeComponentHandler,
|
|
4
|
+
addNativeComponentLayoutInvalidationListener
|
|
5
|
+
} from "./nativecomponent.js";
|
|
6
|
+
import { measureElement } from "./dom.js";
|
|
7
|
+
import { ensureComponentId, NativeComponentUpdateState, iOSNativeComponentHelper } from "./component.js";
|
|
8
|
+
import { isAndroid, isHarmony, isIOS } from "./platform.js";
|
|
9
|
+
|
|
10
|
+
const HARMONY_PROPS_PREFIX = "data:application/json,";
|
|
11
|
+
|
|
12
|
+
// Type definitions
|
|
13
|
+
export type LxVideoQuality = { label: string; url?: string };
|
|
14
|
+
type LxVideoViewEventHandler = (e: Event) => void;
|
|
15
|
+
|
|
16
|
+
export type LxVideoAttributes = {
|
|
17
|
+
id?: string;
|
|
18
|
+
src?: string;
|
|
19
|
+
poster?: string;
|
|
20
|
+
objectFit?: "cover" | "contain" | "fill" | "fit";
|
|
21
|
+
rotate?: 0 | 90 | 180 | 270;
|
|
22
|
+
autoplay?: boolean;
|
|
23
|
+
loop?: boolean;
|
|
24
|
+
muted?: boolean;
|
|
25
|
+
controls?: boolean;
|
|
26
|
+
progressBar?: boolean;
|
|
27
|
+
live?: boolean;
|
|
28
|
+
volume?: string | number;
|
|
29
|
+
qualities?: LxVideoQuality[]; // First is default
|
|
30
|
+
playbackRates?: number[]; // First is default
|
|
31
|
+
className?: string;
|
|
32
|
+
style?: any;
|
|
33
|
+
ref?: any;
|
|
34
|
+
// Event handlers (framework-native syntax)
|
|
35
|
+
onPlayRequest?: LxVideoViewEventHandler;
|
|
36
|
+
onPlay?: LxVideoViewEventHandler;
|
|
37
|
+
onPlaying?: LxVideoViewEventHandler;
|
|
38
|
+
onPause?: LxVideoViewEventHandler;
|
|
39
|
+
onStop?: LxVideoViewEventHandler;
|
|
40
|
+
onEnded?: LxVideoViewEventHandler;
|
|
41
|
+
onTimeUpdate?: LxVideoViewEventHandler;
|
|
42
|
+
onError?: LxVideoViewEventHandler;
|
|
43
|
+
onLoadedMetadata?: LxVideoViewEventHandler;
|
|
44
|
+
onFullscreenChange?: LxVideoViewEventHandler;
|
|
45
|
+
onWaiting?: LxVideoViewEventHandler;
|
|
46
|
+
onQualityChange?: LxVideoViewEventHandler;
|
|
47
|
+
onRateChange?: LxVideoViewEventHandler;
|
|
48
|
+
// Logic bindings (CLI-generated, maps event name → Logic function name)
|
|
49
|
+
pageBindings?: Record<string, string>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type LxObjectFit = "cover" | "contain" | "fill" | "fit";
|
|
53
|
+
|
|
54
|
+
declare global {
|
|
55
|
+
namespace JSX {
|
|
56
|
+
interface IntrinsicElements {
|
|
57
|
+
"lx-video": LxVideoAttributes;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Component implementation
|
|
63
|
+
export class LxVideoElement extends HTMLElement {
|
|
64
|
+
static get observedAttributes() {
|
|
65
|
+
return [
|
|
66
|
+
"id",
|
|
67
|
+
"src",
|
|
68
|
+
"poster",
|
|
69
|
+
"object-fit",
|
|
70
|
+
"rotate",
|
|
71
|
+
"autoplay",
|
|
72
|
+
"loop",
|
|
73
|
+
"muted",
|
|
74
|
+
"controls",
|
|
75
|
+
"progress-bar",
|
|
76
|
+
"live",
|
|
77
|
+
"volume",
|
|
78
|
+
"qualities",
|
|
79
|
+
"playback-rates"
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private componentId: string | null = null;
|
|
84
|
+
private mounted = false;
|
|
85
|
+
private updateState = new NativeComponentUpdateState();
|
|
86
|
+
private unregister?: () => void;
|
|
87
|
+
private resizeObserver?: ResizeObserver;
|
|
88
|
+
private pendingLayoutFrame: number | null = null;
|
|
89
|
+
private boundUpdatePosition = this.updatePosition.bind(this);
|
|
90
|
+
private _handlers: Record<string, EventListenerOrEventListenerObject> = {};
|
|
91
|
+
private harmonyEmbed?: HTMLEmbedElement;
|
|
92
|
+
private lastHarmonyProps?: string;
|
|
93
|
+
private pendingHarmonyProps?: Record<string, unknown>;
|
|
94
|
+
private pendingHarmonyRetryTimer: number | null = null;
|
|
95
|
+
private pendingHarmonyRetryCount: number = 0;
|
|
96
|
+
private iOSBootstrapFrame: number | null = null;
|
|
97
|
+
private iOSBootstrapRemaining: number = 0;
|
|
98
|
+
private iOSHelper?: iOSNativeComponentHelper;
|
|
99
|
+
private removeLayoutInvalidationListener?: () => void;
|
|
100
|
+
private forceHarmonyEmbedRecreate: boolean = false;
|
|
101
|
+
private attrObserver?: MutationObserver;
|
|
102
|
+
private resizeEventListener: EventListenerObject = {
|
|
103
|
+
handleEvent: () => this.boundUpdatePosition(),
|
|
104
|
+
};
|
|
105
|
+
private iOSScrollEventListener: EventListenerObject = {
|
|
106
|
+
handleEvent: () => this.boundUpdatePosition(),
|
|
107
|
+
};
|
|
108
|
+
private rawHandlers: Record<string, EventListenerOrEventListenerObject> = {};
|
|
109
|
+
private _pageBindings: Record<string, string> = {};
|
|
110
|
+
|
|
111
|
+
private parseRotateValue(value: unknown): 0 | 90 | 180 | 270 | undefined {
|
|
112
|
+
const parsed = (() => {
|
|
113
|
+
if (typeof value === "number") {
|
|
114
|
+
return Number.isInteger(value) ? value : NaN;
|
|
115
|
+
}
|
|
116
|
+
if (typeof value === "string") {
|
|
117
|
+
const normalized = value.trim();
|
|
118
|
+
if (!/^(0|90|180|270)$/.test(normalized)) return NaN;
|
|
119
|
+
return Number(normalized);
|
|
120
|
+
}
|
|
121
|
+
return NaN;
|
|
122
|
+
})();
|
|
123
|
+
if (parsed === 0 || parsed === 90 || parsed === 180 || parsed === 270) {
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private parseObjectFitValue(value: unknown): LxObjectFit | undefined {
|
|
130
|
+
if (typeof value !== "string") return undefined;
|
|
131
|
+
const normalized = value.trim().toLowerCase();
|
|
132
|
+
if (
|
|
133
|
+
normalized === "cover" ||
|
|
134
|
+
normalized === "contain" ||
|
|
135
|
+
normalized === "fill" ||
|
|
136
|
+
normalized === "fit"
|
|
137
|
+
) {
|
|
138
|
+
return normalized;
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
set pageBindings(bindings: Record<string, string>) {
|
|
144
|
+
this._pageBindings = bindings ?? {};
|
|
145
|
+
if (this.isConnected) {
|
|
146
|
+
this.mountOrUpdate(true);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get pageBindings(): Record<string, string> {
|
|
151
|
+
return this._pageBindings;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private dataAttrToDatasetKey(attr: string): string {
|
|
155
|
+
const raw = attr.slice(5).trim();
|
|
156
|
+
if (!raw) return "";
|
|
157
|
+
const parts = raw.split("-").filter(Boolean);
|
|
158
|
+
if (parts.length === 0) return "";
|
|
159
|
+
return parts
|
|
160
|
+
.map((segment, index) => {
|
|
161
|
+
if (index === 0) return segment.toLowerCase();
|
|
162
|
+
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
163
|
+
})
|
|
164
|
+
.join("");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private collectDataset(): Record<string, string> {
|
|
168
|
+
const dataset: Record<string, string> = {};
|
|
169
|
+
const attrs = this.getAttributeNames();
|
|
170
|
+
for (const attr of attrs) {
|
|
171
|
+
if (!attr.startsWith("data-")) continue;
|
|
172
|
+
const key = this.dataAttrToDatasetKey(attr);
|
|
173
|
+
if (!key) continue;
|
|
174
|
+
const value = this.getAttribute(attr);
|
|
175
|
+
if (value == null) continue;
|
|
176
|
+
dataset[key] = value;
|
|
177
|
+
}
|
|
178
|
+
return dataset;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private upgradeProperty(propName: string): void {
|
|
182
|
+
const self = this as unknown as Record<string, unknown>;
|
|
183
|
+
if (!Object.prototype.hasOwnProperty.call(self, propName)) return;
|
|
184
|
+
const value = self[propName];
|
|
185
|
+
delete self[propName];
|
|
186
|
+
self[propName] = value;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
set rotate(value: unknown) {
|
|
190
|
+
const normalized = this.parseRotateValue(value);
|
|
191
|
+
if (normalized === undefined) {
|
|
192
|
+
this.removeAttribute("rotate");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.setAttribute("rotate", String(normalized));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get rotate(): 0 | 90 | 180 | 270 | undefined {
|
|
199
|
+
return this.parseRotateValue(this.getAttribute("rotate"));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
connectedCallback() {
|
|
203
|
+
// React may set custom-element properties before upgrade; replay through setters.
|
|
204
|
+
this.upgradeProperty("pageBindings");
|
|
205
|
+
this.upgradeProperty("rotate");
|
|
206
|
+
this.upgradeProperty("onplayrequest");
|
|
207
|
+
this.upgradeProperty("onplay");
|
|
208
|
+
this.upgradeProperty("onplaying");
|
|
209
|
+
this.upgradeProperty("onpause");
|
|
210
|
+
this.upgradeProperty("onstop");
|
|
211
|
+
this.upgradeProperty("onended");
|
|
212
|
+
this.upgradeProperty("ontimeupdate");
|
|
213
|
+
this.upgradeProperty("onloadedmetadata");
|
|
214
|
+
this.upgradeProperty("onfullscreenchange");
|
|
215
|
+
this.upgradeProperty("onwaiting");
|
|
216
|
+
this.upgradeProperty("onqualitychange");
|
|
217
|
+
this.upgradeProperty("onratechange");
|
|
218
|
+
|
|
219
|
+
this.componentId = ensureComponentId(this, "lx-video", this.componentId);
|
|
220
|
+
if (!this.componentId) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
this.unregister = registerNativeComponentHandler(this.componentId!, (message) => {
|
|
224
|
+
// Handle component events from native
|
|
225
|
+
if (message.event) {
|
|
226
|
+
// Normalize detail based on event type
|
|
227
|
+
let detail = message.detail || message.payload || {};
|
|
228
|
+
|
|
229
|
+
// Ensure common state events have empty detail if not provided
|
|
230
|
+
if (['playrequest', 'play', 'playing', 'pause', 'stop', 'ended', 'waiting'].includes(message.event)) {
|
|
231
|
+
if (Object.keys(detail).length === 0) detail = {};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const ev = new CustomEvent(message.event, {
|
|
235
|
+
detail: detail,
|
|
236
|
+
bubbles: true,
|
|
237
|
+
cancelable: false
|
|
238
|
+
});
|
|
239
|
+
this.dispatchEvent(ev);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
this.ensurePlaceholderStyle();
|
|
243
|
+
|
|
244
|
+
// Setup iOS native component rendering helper (no-op on other platforms)
|
|
245
|
+
this.iOSHelper = new iOSNativeComponentHelper(this, this.componentId);
|
|
246
|
+
this.iOSHelper.setup();
|
|
247
|
+
this.startAttrObserver();
|
|
248
|
+
|
|
249
|
+
// iOS bootstrap:
|
|
250
|
+
// avoid hardcoded timeout; run an immediate mount plus a few frame-based refreshes.
|
|
251
|
+
if (isIOS()) {
|
|
252
|
+
this.mountOrUpdate(true);
|
|
253
|
+
this.startTracking();
|
|
254
|
+
this.scheduleIOSBootstrapFrames();
|
|
255
|
+
} else {
|
|
256
|
+
this.mountOrUpdate();
|
|
257
|
+
this.startTracking();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
disconnectedCallback() {
|
|
262
|
+
this.updateState.reset();
|
|
263
|
+
|
|
264
|
+
if (this.componentId && !isHarmony()) {
|
|
265
|
+
sendNativeComponentMessage({
|
|
266
|
+
action: "component.unmount",
|
|
267
|
+
id: this.componentId
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
this.stopTracking();
|
|
271
|
+
if (this.unregister) {
|
|
272
|
+
this.unregister();
|
|
273
|
+
this.unregister = undefined;
|
|
274
|
+
}
|
|
275
|
+
if (this.harmonyEmbed && this.contains(this.harmonyEmbed)) {
|
|
276
|
+
this.removeChild(this.harmonyEmbed);
|
|
277
|
+
}
|
|
278
|
+
this.harmonyEmbed = undefined;
|
|
279
|
+
this.lastHarmonyProps = undefined;
|
|
280
|
+
|
|
281
|
+
// Cleanup iOS helper
|
|
282
|
+
this.iOSHelper?.cleanup();
|
|
283
|
+
this.iOSHelper = undefined;
|
|
284
|
+
this.stopAttrObserver();
|
|
285
|
+
|
|
286
|
+
// Cleanup manually attached property handlers
|
|
287
|
+
Object.keys(this._handlers).forEach(name => {
|
|
288
|
+
this.removeEventListener(name, this._handlers[name]);
|
|
289
|
+
});
|
|
290
|
+
this._handlers = {};
|
|
291
|
+
this.rawHandlers = {};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
attributeChangedCallback(name: string) {
|
|
295
|
+
if (name === "id") {
|
|
296
|
+
const prev = this.componentId;
|
|
297
|
+
this.componentId = ensureComponentId(this, "lx-video", this.componentId);
|
|
298
|
+
if (prev && this.componentId !== prev) {
|
|
299
|
+
this.mounted = false;
|
|
300
|
+
this.updateState.reset();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!this.isConnected) return;
|
|
304
|
+
// Keep rotate/object-fit updates immediate across all native platforms.
|
|
305
|
+
const forcePropsUpdate = name === "rotate" || name === "object-fit" || name === "objectfit";
|
|
306
|
+
if (isHarmony() && forcePropsUpdate) {
|
|
307
|
+
// Bridge callbacks can race on Harmony; force a deterministic embed recreate for visual props.
|
|
308
|
+
this.forceHarmonyEmbedRecreate = true;
|
|
309
|
+
}
|
|
310
|
+
this.mountOrUpdate(forcePropsUpdate);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private isEventListener(value: unknown): value is EventListenerOrEventListenerObject {
|
|
314
|
+
if (typeof value === "function") return true;
|
|
315
|
+
if (!value || typeof value !== "object") return false;
|
|
316
|
+
return typeof (value as EventListenerObject).handleEvent === "function";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private setEventHandler(name: string, value: unknown) {
|
|
320
|
+
const current = this._handlers[name];
|
|
321
|
+
if (current) {
|
|
322
|
+
this.removeEventListener(name, current);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (this.isEventListener(value)) {
|
|
326
|
+
const listener =
|
|
327
|
+
typeof value === "function"
|
|
328
|
+
? ({ handleEvent: value } as EventListenerObject)
|
|
329
|
+
: value;
|
|
330
|
+
this._handlers[name] = listener;
|
|
331
|
+
this.rawHandlers[name] = value;
|
|
332
|
+
this.addEventListener(name, listener);
|
|
333
|
+
} else {
|
|
334
|
+
delete this._handlers[name];
|
|
335
|
+
delete this.rawHandlers[name];
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private getEventHandler(name: string) {
|
|
340
|
+
return (this.rawHandlers[name] || null) as any;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Standard Media Events for React Props (e.g. <lx-video onPlay={...} />)
|
|
344
|
+
set onplayrequest(cb: EventListener) { this.setEventHandler('playrequest', cb); }
|
|
345
|
+
get onplayrequest() { return this.getEventHandler('playrequest'); }
|
|
346
|
+
|
|
347
|
+
set onplay(cb: EventListener) { this.setEventHandler('play', cb); }
|
|
348
|
+
get onplay() { return this.getEventHandler('play'); }
|
|
349
|
+
|
|
350
|
+
set onplaying(cb: EventListener) { this.setEventHandler('playing', cb); }
|
|
351
|
+
get onplaying() { return this.getEventHandler('playing'); }
|
|
352
|
+
|
|
353
|
+
set onpause(cb: EventListener) { this.setEventHandler('pause', cb); }
|
|
354
|
+
get onpause() { return this.getEventHandler('pause'); }
|
|
355
|
+
|
|
356
|
+
set onstop(cb: EventListener) { this.setEventHandler('stop', cb); }
|
|
357
|
+
get onstop() { return this.getEventHandler('stop'); }
|
|
358
|
+
|
|
359
|
+
set onended(cb: EventListener) { this.setEventHandler('ended', cb); }
|
|
360
|
+
get onended() { return this.getEventHandler('ended'); }
|
|
361
|
+
|
|
362
|
+
set ontimeupdate(cb: EventListener) { this.setEventHandler('timeupdate', cb); }
|
|
363
|
+
get ontimeupdate() { return this.getEventHandler('timeupdate'); }
|
|
364
|
+
|
|
365
|
+
// Note: onerror is inherited from HTMLElement with OnErrorEventHandler type.
|
|
366
|
+
// Use addEventListener('error', handler) for error events instead.
|
|
367
|
+
|
|
368
|
+
set onloadedmetadata(cb: EventListener) { this.setEventHandler('loadedmetadata', cb); }
|
|
369
|
+
get onloadedmetadata() { return this.getEventHandler('loadedmetadata'); }
|
|
370
|
+
|
|
371
|
+
set onfullscreenchange(cb: EventListener) { this.setEventHandler('fullscreenchange', cb); }
|
|
372
|
+
get onfullscreenchange() { return this.getEventHandler('fullscreenchange'); }
|
|
373
|
+
|
|
374
|
+
set onwaiting(cb: EventListener) { this.setEventHandler('waiting', cb); }
|
|
375
|
+
get onwaiting() { return this.getEventHandler('waiting'); }
|
|
376
|
+
|
|
377
|
+
set onqualitychange(cb: EventListener) { this.setEventHandler('qualitychange', cb); }
|
|
378
|
+
get onqualitychange() { return this.getEventHandler('qualitychange'); }
|
|
379
|
+
|
|
380
|
+
set onratechange(cb: EventListener) { this.setEventHandler('ratechange', cb); }
|
|
381
|
+
get onratechange() { return this.getEventHandler('ratechange'); }
|
|
382
|
+
|
|
383
|
+
// Internal
|
|
384
|
+
private ensurePlaceholderStyle() {
|
|
385
|
+
if (!this.style.display) this.style.display = "block";
|
|
386
|
+
if (!this.style.position) this.style.position = "relative"; // Needed for iOS scroll container
|
|
387
|
+
if (!this.style.backgroundColor) this.style.backgroundColor = "black";
|
|
388
|
+
if (!this.style.aspectRatio) this.style.aspectRatio = "16 / 9";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
private collectProps() {
|
|
392
|
+
const volumeAttr = this.getAttribute("volume");
|
|
393
|
+
const volume =
|
|
394
|
+
volumeAttr != null ? parseFloat(volumeAttr) : undefined;
|
|
395
|
+
const rawObjectFit =
|
|
396
|
+
this.getAttribute("object-fit") ??
|
|
397
|
+
this.getAttribute("objectfit");
|
|
398
|
+
const objectFit = this.parseObjectFitValue(rawObjectFit);
|
|
399
|
+
|
|
400
|
+
// JSON-encoded arrays (React wrapper sets these via JSON.stringify)
|
|
401
|
+
const qualitiesAttr = this.getAttribute("qualities");
|
|
402
|
+
let qualities: LxVideoQuality[] | undefined;
|
|
403
|
+
if (qualitiesAttr) {
|
|
404
|
+
try {
|
|
405
|
+
const parsed = JSON.parse(qualitiesAttr);
|
|
406
|
+
if (Array.isArray(parsed)) {
|
|
407
|
+
qualities = parsed.filter((q): q is LxVideoQuality =>
|
|
408
|
+
q && typeof q === "object" && typeof (q as { label?: unknown }).label === "string"
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Parse playbackRates array from JSON attribute
|
|
415
|
+
const playbackRatesAttr = this.getAttribute("playback-rates");
|
|
416
|
+
let playbackRates: number[] | undefined;
|
|
417
|
+
if (playbackRatesAttr) {
|
|
418
|
+
try {
|
|
419
|
+
const parsed = JSON.parse(playbackRatesAttr);
|
|
420
|
+
if (Array.isArray(parsed)) {
|
|
421
|
+
playbackRates = parsed.filter((n): n is number => typeof n === "number");
|
|
422
|
+
}
|
|
423
|
+
} catch {}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const isLive = this.hasAttribute("live");
|
|
427
|
+
// When live, default progressBar to false unless explicitly enabled.
|
|
428
|
+
const progressBarAttr = this.getAttribute("progress-bar");
|
|
429
|
+
const progressBar = isLive
|
|
430
|
+
? (progressBarAttr !== null && progressBarAttr !== "false")
|
|
431
|
+
: (progressBarAttr !== "false");
|
|
432
|
+
|
|
433
|
+
const rawRotate = this.getAttribute("rotate");
|
|
434
|
+
const validRotate = this.parseRotateValue(rawRotate);
|
|
435
|
+
const pageFuncBindings = this._pageBindings;
|
|
436
|
+
const dataset = this.collectDataset();
|
|
437
|
+
const clearProps: string[] = [];
|
|
438
|
+
if (rawRotate === null || validRotate === undefined) {
|
|
439
|
+
clearProps.push("rotate");
|
|
440
|
+
}
|
|
441
|
+
if (rawObjectFit == null || objectFit === undefined) {
|
|
442
|
+
clearProps.push("objectFit");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
src: this.getAttribute("src") || undefined,
|
|
447
|
+
poster: this.getAttribute("poster") || undefined,
|
|
448
|
+
autoplay: this.hasAttribute("autoplay"),
|
|
449
|
+
loop: this.hasAttribute("loop"),
|
|
450
|
+
muted: this.hasAttribute("muted"),
|
|
451
|
+
controls: this.hasAttribute("controls"),
|
|
452
|
+
progressBar,
|
|
453
|
+
live: isLive,
|
|
454
|
+
volume: !Number.isNaN(volume ?? NaN) ? volume : undefined,
|
|
455
|
+
objectFit,
|
|
456
|
+
rotate: validRotate,
|
|
457
|
+
...(clearProps.length > 0 ? { __clearProps: clearProps } : {}),
|
|
458
|
+
qualities,
|
|
459
|
+
playbackRates,
|
|
460
|
+
dataset,
|
|
461
|
+
datasetJson: JSON.stringify(dataset),
|
|
462
|
+
pageFuncBindings,
|
|
463
|
+
pageFuncBindingsJson: JSON.stringify(pageFuncBindings)
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private shouldRefreshForAttribute(name: string): boolean {
|
|
468
|
+
const normalized = name.trim().toLowerCase();
|
|
469
|
+
return normalized.startsWith("data-");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private startAttrObserver(): void {
|
|
473
|
+
if (typeof MutationObserver === "undefined" || this.attrObserver) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.attrObserver = new MutationObserver((mutations) => {
|
|
477
|
+
for (const mutation of mutations) {
|
|
478
|
+
const attrName = mutation.attributeName;
|
|
479
|
+
if (!attrName || !this.shouldRefreshForAttribute(attrName)) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
this.mountOrUpdate(true);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
this.attrObserver.observe(this, { attributes: true });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private stopAttrObserver(): void {
|
|
490
|
+
if (!this.attrObserver) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.attrObserver.disconnect();
|
|
494
|
+
this.attrObserver = undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private measureForNative() {
|
|
498
|
+
const measured = measureElement(this);
|
|
499
|
+
if (!isIOS()) {
|
|
500
|
+
return measured;
|
|
501
|
+
}
|
|
502
|
+
const rect = this.getBoundingClientRect();
|
|
503
|
+
return {
|
|
504
|
+
rect: {
|
|
505
|
+
x: rect.left,
|
|
506
|
+
y: rect.top,
|
|
507
|
+
width: rect.width,
|
|
508
|
+
height: rect.height
|
|
509
|
+
},
|
|
510
|
+
cornerRadius: measured.cornerRadius
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private mountOrUpdate(forceUpdate = false) {
|
|
515
|
+
if (isHarmony()) {
|
|
516
|
+
this.mountOrUpdateHarmony(forceUpdate);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (!this.componentId) return;
|
|
520
|
+
const { rect, cornerRadius } = this.measureForNative();
|
|
521
|
+
const hasSize = rect.width > 0 && rect.height > 0;
|
|
522
|
+
if (!hasSize) {
|
|
523
|
+
// Defer and try again once layout stabilizes; only needed before first mount
|
|
524
|
+
if (!this.mounted) {
|
|
525
|
+
this.scheduleMountRetry();
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const props = this.collectProps();
|
|
530
|
+
if (cornerRadius !== undefined) {
|
|
531
|
+
(props as any).cornerRadius = cornerRadius;
|
|
532
|
+
}
|
|
533
|
+
const zIndex = parseFloat(this.style.zIndex || "0") || 0;
|
|
534
|
+
const decision = this.updateState.decide(rect, props, zIndex, !this.mounted || forceUpdate);
|
|
535
|
+
if (!decision.shouldSend) return;
|
|
536
|
+
|
|
537
|
+
const payload: any = {
|
|
538
|
+
action: this.mounted ? "component.update" : "component.mount",
|
|
539
|
+
id: this.componentId,
|
|
540
|
+
type: "video.native",
|
|
541
|
+
rect,
|
|
542
|
+
zIndex,
|
|
543
|
+
...(cornerRadius !== undefined ? { cornerRadius } : {})
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
// Enhance payload with platform-specific data (iOS scroll container rect)
|
|
547
|
+
this.iOSHelper?.enhancePayload(payload);
|
|
548
|
+
|
|
549
|
+
// Only include props when they changed or on first mount
|
|
550
|
+
if (decision.propsChanged || !this.mounted) {
|
|
551
|
+
payload.props = props;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
sendNativeComponentMessage(payload);
|
|
555
|
+
this.mounted = true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private updatePosition() {
|
|
559
|
+
if (isHarmony()) {
|
|
560
|
+
this.mountOrUpdateHarmony();
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (!this.mounted || !this.componentId) return;
|
|
564
|
+
const { rect, cornerRadius } = this.measureForNative();
|
|
565
|
+
if (!rect.width || !rect.height) return;
|
|
566
|
+
const zIndex = parseFloat(this.style.zIndex || "0") || 0;
|
|
567
|
+
const decision = this.updateState.decide(rect, null, zIndex);
|
|
568
|
+
if (!decision.shouldSend) return;
|
|
569
|
+
|
|
570
|
+
const payload: any = {
|
|
571
|
+
action: "component.update",
|
|
572
|
+
id: this.componentId,
|
|
573
|
+
rect,
|
|
574
|
+
zIndex
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
// Keep iOS scroll-container metadata on update so native side can still
|
|
578
|
+
// promote fallback overlay into WKChildScrollView when it becomes available.
|
|
579
|
+
this.iOSHelper?.enhancePayload(payload);
|
|
580
|
+
|
|
581
|
+
if (cornerRadius !== undefined) {
|
|
582
|
+
payload.cornerRadius = cornerRadius;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
sendNativeComponentMessage(payload);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Start tracking component size changes.
|
|
590
|
+
*/
|
|
591
|
+
private startTracking() {
|
|
592
|
+
window.addEventListener("resize", this.resizeEventListener);
|
|
593
|
+
if (isIOS()) {
|
|
594
|
+
window.addEventListener("scroll", this.iOSScrollEventListener, { passive: true });
|
|
595
|
+
}
|
|
596
|
+
if (isAndroid()) {
|
|
597
|
+
this.removeLayoutInvalidationListener = addNativeComponentLayoutInvalidationListener(this.resizeEventListener);
|
|
598
|
+
}
|
|
599
|
+
this.startSizeObserver();
|
|
600
|
+
this.updatePosition();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private stopTracking() {
|
|
604
|
+
window.removeEventListener("resize", this.resizeEventListener);
|
|
605
|
+
if (isIOS()) {
|
|
606
|
+
window.removeEventListener("scroll", this.iOSScrollEventListener);
|
|
607
|
+
}
|
|
608
|
+
this.removeLayoutInvalidationListener?.();
|
|
609
|
+
this.removeLayoutInvalidationListener = undefined;
|
|
610
|
+
this.stopSizeObserver();
|
|
611
|
+
if (this.pendingLayoutFrame !== null) {
|
|
612
|
+
cancelAnimationFrame(this.pendingLayoutFrame);
|
|
613
|
+
this.pendingLayoutFrame = null;
|
|
614
|
+
}
|
|
615
|
+
if (this.pendingHarmonyRetryTimer !== null) {
|
|
616
|
+
clearTimeout(this.pendingHarmonyRetryTimer);
|
|
617
|
+
this.pendingHarmonyRetryTimer = null;
|
|
618
|
+
}
|
|
619
|
+
if (this.iOSBootstrapFrame !== null) {
|
|
620
|
+
cancelAnimationFrame(this.iOSBootstrapFrame);
|
|
621
|
+
this.iOSBootstrapFrame = null;
|
|
622
|
+
}
|
|
623
|
+
this.iOSBootstrapRemaining = 0;
|
|
624
|
+
this.pendingHarmonyProps = undefined;
|
|
625
|
+
this.pendingHarmonyRetryCount = 0;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private startSizeObserver() {
|
|
629
|
+
if (typeof ResizeObserver === "undefined") {
|
|
630
|
+
// ResizeObserver unavailable: retry mount on next frame.
|
|
631
|
+
this.scheduleMountRetry();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (this.resizeObserver) return;
|
|
635
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
636
|
+
for (const entry of entries) {
|
|
637
|
+
if (entry.target !== this) continue;
|
|
638
|
+
const { width, height } = entry.contentRect;
|
|
639
|
+
if (!width || !height) continue;
|
|
640
|
+
if (this.mounted) {
|
|
641
|
+
this.updatePosition();
|
|
642
|
+
} else {
|
|
643
|
+
this.mountOrUpdate();
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
this.resizeObserver.observe(this);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private stopSizeObserver() {
|
|
651
|
+
if (this.resizeObserver) {
|
|
652
|
+
this.resizeObserver.disconnect();
|
|
653
|
+
this.resizeObserver = undefined;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private scheduleMountRetry() {
|
|
658
|
+
if (this.pendingLayoutFrame !== null || this.mounted) return;
|
|
659
|
+
this.pendingLayoutFrame = requestAnimationFrame(() => {
|
|
660
|
+
this.pendingLayoutFrame = null;
|
|
661
|
+
this.mountOrUpdate();
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private scheduleIOSBootstrapFrames(): void {
|
|
666
|
+
if (!isIOS()) return;
|
|
667
|
+
// Keep a short frame-based bootstrap window so late-created WKChildScrollView
|
|
668
|
+
// can still receive mount/update without relying on hardcoded millisecond delay.
|
|
669
|
+
this.iOSBootstrapRemaining = 8;
|
|
670
|
+
const tick = () => {
|
|
671
|
+
if (!this.isConnected) {
|
|
672
|
+
this.iOSBootstrapFrame = null;
|
|
673
|
+
this.iOSBootstrapRemaining = 0;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
this.mountOrUpdate(true);
|
|
677
|
+
this.iOSBootstrapRemaining -= 1;
|
|
678
|
+
if (this.iOSBootstrapRemaining <= 0) {
|
|
679
|
+
this.iOSBootstrapFrame = null;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
this.iOSBootstrapFrame = requestAnimationFrame(tick);
|
|
683
|
+
};
|
|
684
|
+
this.iOSBootstrapFrame = requestAnimationFrame(tick);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private mountOrUpdateHarmony(forceUpdate = false) {
|
|
688
|
+
if (!this.componentId) return;
|
|
689
|
+
const { rect, cornerRadius } = measureElement(this);
|
|
690
|
+
const hasSize = rect.width > 0 && rect.height > 0;
|
|
691
|
+
if (!hasSize) {
|
|
692
|
+
if (!this.mounted) {
|
|
693
|
+
this.scheduleMountRetry();
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const props = this.collectProps();
|
|
698
|
+
if (cornerRadius !== undefined) {
|
|
699
|
+
(props as any).cornerRadius = cornerRadius;
|
|
700
|
+
}
|
|
701
|
+
const zIndex = parseFloat(this.style.zIndex || "0") || 0;
|
|
702
|
+
const decision = this.updateState.decide(rect, props, zIndex, !this.mounted || forceUpdate);
|
|
703
|
+
if (!decision.shouldSend) return;
|
|
704
|
+
this.ensureHarmonyEmbed(
|
|
705
|
+
rect,
|
|
706
|
+
props,
|
|
707
|
+
cornerRadius,
|
|
708
|
+
this.forceHarmonyEmbedRecreate
|
|
709
|
+
);
|
|
710
|
+
this.forceHarmonyEmbedRecreate = false;
|
|
711
|
+
this.mounted = true;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private ensureHarmonyEmbed(
|
|
715
|
+
rect: { width: number; height: number },
|
|
716
|
+
props: Record<string, unknown>,
|
|
717
|
+
cornerRadius?: number,
|
|
718
|
+
forceRecreate = false
|
|
719
|
+
) {
|
|
720
|
+
const createEmbed = (): HTMLEmbedElement => {
|
|
721
|
+
const embed = document.createElement("embed");
|
|
722
|
+
embed.setAttribute("type", "native/video");
|
|
723
|
+
embed.setAttribute("width", `${rect.width}`);
|
|
724
|
+
embed.setAttribute("height", `${rect.height}`);
|
|
725
|
+
embed.setAttribute("id", this.componentId!);
|
|
726
|
+
embed.setAttribute("data-lx-component-id", this.componentId!);
|
|
727
|
+
embed.style.display = "block";
|
|
728
|
+
embed.style.width = "100%";
|
|
729
|
+
embed.style.height = "100%";
|
|
730
|
+
embed.style.border = "none";
|
|
731
|
+
if (cornerRadius !== undefined) {
|
|
732
|
+
embed.style.borderRadius = `${cornerRadius}px`;
|
|
733
|
+
embed.style.overflow = "hidden";
|
|
734
|
+
}
|
|
735
|
+
const encodedProps = this.encodeHarmonyProps(props);
|
|
736
|
+
embed.setAttribute("src", encodedProps);
|
|
737
|
+
this.lastHarmonyProps = encodedProps;
|
|
738
|
+
return embed;
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
if (forceRecreate && this.harmonyEmbed) {
|
|
742
|
+
const oldEmbed = this.harmonyEmbed;
|
|
743
|
+
this.harmonyEmbed = undefined;
|
|
744
|
+
if (this.contains(oldEmbed)) {
|
|
745
|
+
this.removeChild(oldEmbed);
|
|
746
|
+
}
|
|
747
|
+
this.lastHarmonyProps = undefined;
|
|
748
|
+
const recreated = createEmbed();
|
|
749
|
+
this.appendChild(recreated);
|
|
750
|
+
this.harmonyEmbed = recreated;
|
|
751
|
+
this.pushHarmonyPropsUpdate(props);
|
|
752
|
+
this.pendingHarmonyRetryCount = 0;
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Create embed element only once - ArkWeb triggers DESTROY+CREATE on attribute changes
|
|
757
|
+
if (!this.harmonyEmbed) {
|
|
758
|
+
const embed = createEmbed();
|
|
759
|
+
this.pushHarmonyPropsUpdate(props);
|
|
760
|
+
|
|
761
|
+
this.appendChild(embed);
|
|
762
|
+
this.harmonyEmbed = embed;
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Only update props when they changed.
|
|
767
|
+
const encodedProps = this.encodeHarmonyProps(props);
|
|
768
|
+
if (encodedProps !== this.lastHarmonyProps) {
|
|
769
|
+
const pushed = this.pushHarmonyPropsUpdate(props);
|
|
770
|
+
if (pushed) {
|
|
771
|
+
this.lastHarmonyProps = encodedProps;
|
|
772
|
+
this.pendingHarmonyProps = undefined;
|
|
773
|
+
this.pendingHarmonyRetryCount = 0;
|
|
774
|
+
} else {
|
|
775
|
+
this.scheduleHarmonyPropsRetry(props);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Ensure embed element matches latest corner radius for clipping
|
|
780
|
+
if (cornerRadius !== undefined && this.harmonyEmbed.style.borderRadius !== `${cornerRadius}px`) {
|
|
781
|
+
this.harmonyEmbed.style.borderRadius = `${cornerRadius}px`;
|
|
782
|
+
this.harmonyEmbed.style.overflow = "hidden";
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private encodeHarmonyProps(props: Record<string, unknown>): string {
|
|
787
|
+
try {
|
|
788
|
+
const payload = { componentId: this.componentId, ...props };
|
|
789
|
+
return `${HARMONY_PROPS_PREFIX}${encodeURIComponent(
|
|
790
|
+
JSON.stringify(payload)
|
|
791
|
+
)}`;
|
|
792
|
+
} catch (_e) {
|
|
793
|
+
return `${HARMONY_PROPS_PREFIX}%7B%7D`;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private pushHarmonyPropsUpdate(props: Record<string, unknown>): boolean {
|
|
798
|
+
if (!this.componentId || !isHarmony()) return false;
|
|
799
|
+
try {
|
|
800
|
+
const proxy = (window as unknown as Record<string, unknown>).LingXiaProxy as
|
|
801
|
+
| { nativeComponentUpdate?: ((componentId: string, payload: string) => void) | ((payload: string) => void) }
|
|
802
|
+
| undefined;
|
|
803
|
+
const updateFn = proxy?.nativeComponentUpdate;
|
|
804
|
+
if (typeof updateFn !== "function") {
|
|
805
|
+
return false;
|
|
806
|
+
}
|
|
807
|
+
const payload = JSON.stringify({ componentId: this.componentId, ...props });
|
|
808
|
+
try {
|
|
809
|
+
(updateFn as (payload: string) => void).call(proxy, payload);
|
|
810
|
+
return true;
|
|
811
|
+
} catch {
|
|
812
|
+
try {
|
|
813
|
+
(updateFn as (componentId: string, payload: string) => void).call(proxy, this.componentId, payload);
|
|
814
|
+
return true;
|
|
815
|
+
} catch {
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
} catch (error) {
|
|
820
|
+
return false;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private scheduleHarmonyPropsRetry(props: Record<string, unknown>): void {
|
|
825
|
+
this.pendingHarmonyProps = { ...props };
|
|
826
|
+
if (this.pendingHarmonyRetryTimer !== null) {
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
this.pendingHarmonyRetryTimer = window.setTimeout(() => {
|
|
830
|
+
this.pendingHarmonyRetryTimer = null;
|
|
831
|
+
if (!this.isConnected || !this.pendingHarmonyProps) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const pendingProps = this.pendingHarmonyProps;
|
|
835
|
+
const pushed = this.pushHarmonyPropsUpdate(pendingProps);
|
|
836
|
+
if (pushed) {
|
|
837
|
+
this.lastHarmonyProps = this.encodeHarmonyProps(pendingProps);
|
|
838
|
+
this.pendingHarmonyProps = undefined;
|
|
839
|
+
this.pendingHarmonyRetryCount = 0;
|
|
840
|
+
} else {
|
|
841
|
+
this.pendingHarmonyRetryCount += 1;
|
|
842
|
+
if (this.pendingHarmonyRetryCount < 20) {
|
|
843
|
+
this.scheduleHarmonyPropsRetry(pendingProps);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}, 100);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function registerVideoComponent() {
|
|
852
|
+
if (!customElements.get("lx-video")) {
|
|
853
|
+
customElements.define("lx-video", LxVideoElement);
|
|
854
|
+
}
|
|
855
|
+
}
|