@smartimpact-it/modern-video-embed 2.0.5 → 2.0.7
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/README.md +322 -71
- package/dist/components/BaseVideoEmbed.d.ts +91 -0
- package/dist/components/BaseVideoEmbed.d.ts.map +1 -0
- package/dist/components/BaseVideoEmbed.js +275 -0
- package/dist/components/BaseVideoEmbed.js.map +1 -0
- package/dist/components/VideoEmbed.d.ts +68 -0
- package/dist/components/VideoEmbed.d.ts.map +1 -0
- package/dist/components/VideoEmbed.js +786 -0
- package/dist/components/VideoEmbed.js.map +1 -0
- package/dist/components/VimeoEmbed.d.ts +26 -36
- package/dist/components/VimeoEmbed.d.ts.map +1 -1
- package/dist/components/VimeoEmbed.js +231 -326
- package/dist/components/VimeoEmbed.js.map +1 -1
- package/dist/components/VimeoEmbed.min.js +1 -1
- package/dist/components/YouTubeEmbed.d.ts +108 -42
- package/dist/components/YouTubeEmbed.d.ts.map +1 -1
- package/dist/components/YouTubeEmbed.js +361 -375
- package/dist/components/YouTubeEmbed.js.map +1 -1
- package/dist/components/YouTubeEmbed.min.js +1 -1
- package/dist/css/components.css +285 -68
- package/dist/css/components.css.map +1 -1
- package/dist/css/components.min.css +1 -1
- package/dist/css/main.css +285 -68
- package/dist/css/main.css.map +1 -1
- package/dist/css/main.min.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/video-only.d.ts +7 -0
- package/dist/video-only.d.ts.map +1 -0
- package/dist/video-only.js +8 -0
- package/dist/video-only.js.map +1 -0
- package/dist/vimeo-only.d.ts +2 -2
- package/dist/vimeo-only.d.ts.map +1 -1
- package/dist/vimeo-only.js +2 -2
- package/dist/vimeo-only.js.map +1 -1
- package/dist/vimeo-only.min.js +1 -1
- package/dist/youtube-only.d.ts +2 -2
- package/dist/youtube-only.d.ts.map +1 -1
- package/dist/youtube-only.js +2 -2
- package/dist/youtube-only.js.map +1 -1
- package/dist/youtube-only.min.js +1 -1
- package/package.json +6 -5
- package/src/components/BaseVideoEmbed.ts +335 -0
- package/src/components/VideoEmbed.ts +870 -0
- package/src/components/VideoEmbed.ts.backup +1051 -0
- package/src/components/VimeoEmbed.ts +258 -395
- package/src/components/YouTubeEmbed.ts +378 -432
- package/src/index.ts +1 -0
- package/src/styles/_embed-base.scss +275 -0
- package/src/styles/_shared-functions.scss +56 -0
- package/src/styles/components.scss +4 -3
- package/src/styles/main.scss +7 -5
- package/src/styles/video-embed.scss +55 -0
- package/src/styles/vimeo-embed.scss +8 -248
- package/src/styles/youtube-embed.scss +8 -254
- package/src/types/index.ts +1 -0
- package/src/types/video-embed.d.ts +90 -0
- package/src/video-only.ts +9 -0
- package/src/vimeo-only.ts +2 -2
- package/src/youtube-only.ts +2 -2
|
@@ -1,49 +1,24 @@
|
|
|
1
|
-
|
|
1
|
+
import { BaseVideoEmbed } from "./BaseVideoEmbed.js";
|
|
2
|
+
|
|
3
|
+
export class YouTubeEmbed extends BaseVideoEmbed {
|
|
2
4
|
private iframe: HTMLIFrameElement | null = null;
|
|
3
5
|
private player: YT["Player"] | null = null;
|
|
4
6
|
private static apiLoaded = false;
|
|
5
7
|
private static apiReady = false;
|
|
6
|
-
private static
|
|
7
|
-
private static
|
|
8
|
-
private
|
|
9
|
-
private
|
|
10
|
-
private initialized = false;
|
|
11
|
-
private setCustomControlState: ((playing: boolean) => void) | null = null;
|
|
12
|
-
private updatingAttribute = false; // Flag to prevent recursive attribute updates
|
|
8
|
+
private static apiLoadingPromise: Promise<void> | null = null;
|
|
9
|
+
private static readonly MAX_API_RETRIES = 3;
|
|
10
|
+
private static readonly API_RETRY_DELAY = 2000;
|
|
11
|
+
private static readonly API_LOAD_TIMEOUT = 10000;
|
|
13
12
|
private updatingMutedState = false; // Flag to prevent sync from overwriting programmatic mute changes
|
|
13
|
+
private apiLoadRetries = 0;
|
|
14
14
|
|
|
15
15
|
/** The full YouTube URL (e.g., "https://www.youtube.com/watch?v=...") */
|
|
16
16
|
#url: string = "";
|
|
17
17
|
/** The 11-character YouTube video ID. */
|
|
18
18
|
#videoId: string = "";
|
|
19
|
-
/** Whether the video should autoplay when loaded. Requires `muted` to be true. */
|
|
20
|
-
#autoplay: boolean = false;
|
|
21
|
-
/** Whether the video audio is muted. */
|
|
22
|
-
#muted: boolean = false;
|
|
23
|
-
/** Whether to show the native YouTube player controls. */
|
|
24
|
-
#controls: boolean = false;
|
|
25
|
-
/** Whether to lazy-load the video, showing a poster image until interaction. */
|
|
26
|
-
#lazy: boolean = false;
|
|
27
|
-
/** URL for a custom poster image. Defaults to YouTube's thumbnail. */
|
|
28
|
-
#poster: string = "";
|
|
29
|
-
/** Read-only property to check if the video is currently playing. */
|
|
30
|
-
#playing: boolean = false;
|
|
31
|
-
/** Whether the video should act as a background, covering its container. */
|
|
32
|
-
#background: boolean = false;
|
|
33
19
|
/** Extra parameters to pass to the YouTube player. */
|
|
34
20
|
#playerVars: Record<string, string | number | boolean> = {};
|
|
35
21
|
|
|
36
|
-
// Declare the event listener properties
|
|
37
|
-
private posterClickHandler: EventListener | null = null;
|
|
38
|
-
private keyboardHandler: ((e: KeyboardEvent) => void) | null = null;
|
|
39
|
-
private ariaLiveRegion: HTMLElement | null = null;
|
|
40
|
-
private apiLoadRetries = 0;
|
|
41
|
-
private static readonly MAX_API_RETRIES = 3;
|
|
42
|
-
private static readonly API_RETRY_DELAY = 2000;
|
|
43
|
-
private static readonly API_LOAD_TIMEOUT = 10000;
|
|
44
|
-
private intersectionObserver: IntersectionObserver | null = null;
|
|
45
|
-
private hasLoadedVideo = false;
|
|
46
|
-
|
|
47
22
|
static get observedAttributes() {
|
|
48
23
|
return [
|
|
49
24
|
"url",
|
|
@@ -82,35 +57,36 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
82
57
|
}
|
|
83
58
|
break;
|
|
84
59
|
case "video-id":
|
|
85
|
-
|
|
60
|
+
// Sanitize video ID to prevent XSS
|
|
61
|
+
this.#videoId = this.sanitizeVideoId(newValue || "");
|
|
86
62
|
if (this.initialized) {
|
|
87
63
|
this.reinitializePlayer();
|
|
88
64
|
}
|
|
89
65
|
break;
|
|
90
66
|
case "autoplay":
|
|
91
|
-
this
|
|
67
|
+
this._autoplay = newValue !== null;
|
|
92
68
|
if (
|
|
93
|
-
!this
|
|
94
|
-
this
|
|
95
|
-
!this
|
|
69
|
+
!this._playing &&
|
|
70
|
+
this._autoplay &&
|
|
71
|
+
!this._lazy &&
|
|
96
72
|
this.initialized
|
|
97
73
|
) {
|
|
98
74
|
this.play();
|
|
99
75
|
}
|
|
100
76
|
break;
|
|
101
77
|
case "controls":
|
|
102
|
-
this
|
|
78
|
+
this._controls = newValue !== null;
|
|
103
79
|
if (this.initialized) {
|
|
104
80
|
this.reinitializePlayer();
|
|
105
81
|
}
|
|
106
82
|
break;
|
|
107
83
|
case "lazy":
|
|
108
|
-
this
|
|
84
|
+
this._lazy = newValue !== null;
|
|
109
85
|
break;
|
|
110
86
|
case "muted":
|
|
111
|
-
this
|
|
87
|
+
this._muted = newValue !== null;
|
|
112
88
|
if (this.player && this.playerReady) {
|
|
113
|
-
if (this
|
|
89
|
+
if (this._muted) {
|
|
114
90
|
this.player.mute();
|
|
115
91
|
} else {
|
|
116
92
|
this.player.unMute();
|
|
@@ -118,14 +94,14 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
118
94
|
}
|
|
119
95
|
break;
|
|
120
96
|
case "poster":
|
|
121
|
-
this
|
|
122
|
-
if (this
|
|
97
|
+
this._poster = newValue || "";
|
|
98
|
+
if (this._lazy && !this.player) {
|
|
123
99
|
this.showPoster(this.#videoId);
|
|
124
100
|
}
|
|
125
101
|
break;
|
|
126
102
|
case "background":
|
|
127
|
-
this
|
|
128
|
-
this
|
|
103
|
+
this._background = newValue !== null;
|
|
104
|
+
this.updateBackgroundMode();
|
|
129
105
|
break;
|
|
130
106
|
case "player-vars":
|
|
131
107
|
try {
|
|
@@ -181,12 +157,12 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
181
157
|
* Note: Autoplay is subject to browser policies and usually requires the video to be muted.
|
|
182
158
|
*/
|
|
183
159
|
get autoplay() {
|
|
184
|
-
return this
|
|
160
|
+
return this._autoplay;
|
|
185
161
|
}
|
|
186
162
|
set autoplay(value: boolean) {
|
|
187
|
-
this
|
|
188
|
-
this
|
|
189
|
-
if (!this
|
|
163
|
+
this._autoplay = value;
|
|
164
|
+
this.reflectBooleanAttribute("autoplay", value);
|
|
165
|
+
if (!this._playing && value && !this._lazy && this.initialized) {
|
|
190
166
|
this.play();
|
|
191
167
|
}
|
|
192
168
|
}
|
|
@@ -195,11 +171,16 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
195
171
|
* Gets or sets whether native YouTube player controls are visible.
|
|
196
172
|
*/
|
|
197
173
|
get controls() {
|
|
198
|
-
return this
|
|
174
|
+
return this._controls;
|
|
199
175
|
}
|
|
200
176
|
set controls(value: boolean) {
|
|
201
|
-
|
|
202
|
-
this
|
|
177
|
+
// Background mode videos must not have controls
|
|
178
|
+
if (this._background && value) {
|
|
179
|
+
this.warn("Cannot enable controls on background video");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this._controls = value;
|
|
183
|
+
this.reflectBooleanAttribute("controls", value);
|
|
203
184
|
if (this.initialized) {
|
|
204
185
|
this.reinitializePlayer();
|
|
205
186
|
}
|
|
@@ -210,22 +191,27 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
210
191
|
* If true, a poster is shown, and the video loads only on user interaction.
|
|
211
192
|
*/
|
|
212
193
|
get lazy() {
|
|
213
|
-
return this
|
|
194
|
+
return this._lazy;
|
|
214
195
|
}
|
|
215
196
|
set lazy(value: boolean) {
|
|
216
|
-
this
|
|
217
|
-
this
|
|
197
|
+
this._lazy = value;
|
|
198
|
+
this.reflectBooleanAttribute("lazy", value);
|
|
218
199
|
}
|
|
219
200
|
|
|
220
201
|
/**
|
|
221
202
|
* Gets or sets the muted state of the video.
|
|
222
203
|
*/
|
|
223
204
|
get muted() {
|
|
224
|
-
return this
|
|
205
|
+
return this._muted;
|
|
225
206
|
}
|
|
226
207
|
set muted(value: boolean) {
|
|
227
|
-
|
|
228
|
-
this
|
|
208
|
+
// Background mode videos must be muted
|
|
209
|
+
if (this._background && !value) {
|
|
210
|
+
this.warn("Cannot unmute background video");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this._muted = value;
|
|
214
|
+
this.reflectBooleanAttribute("muted", value);
|
|
229
215
|
if (this.player && this.playerReady) {
|
|
230
216
|
this.updatingMutedState = true;
|
|
231
217
|
if (value) {
|
|
@@ -245,10 +231,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
245
231
|
* If not set, a default YouTube thumbnail is used for lazy-loaded videos.
|
|
246
232
|
*/
|
|
247
233
|
get poster() {
|
|
248
|
-
return this
|
|
234
|
+
return this._poster;
|
|
249
235
|
}
|
|
250
236
|
set poster(value: string) {
|
|
251
|
-
this
|
|
237
|
+
this._poster = value;
|
|
252
238
|
this.updatingAttribute = true;
|
|
253
239
|
if (value) {
|
|
254
240
|
this.setAttribute("poster", value);
|
|
@@ -256,7 +242,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
256
242
|
this.removeAttribute("poster");
|
|
257
243
|
}
|
|
258
244
|
this.updatingAttribute = false;
|
|
259
|
-
if (this
|
|
245
|
+
if (this._lazy && !this.player) {
|
|
260
246
|
this.showPoster(this.#videoId);
|
|
261
247
|
}
|
|
262
248
|
}
|
|
@@ -265,7 +251,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
265
251
|
* Returns `true` if the video is currently playing.
|
|
266
252
|
*/
|
|
267
253
|
get playing() {
|
|
268
|
-
return this
|
|
254
|
+
return this._playing;
|
|
269
255
|
}
|
|
270
256
|
|
|
271
257
|
/**
|
|
@@ -273,12 +259,12 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
273
259
|
* In background mode, the video covers its container, is muted, and has no controls.
|
|
274
260
|
*/
|
|
275
261
|
get background() {
|
|
276
|
-
return this
|
|
262
|
+
return this._background;
|
|
277
263
|
}
|
|
278
264
|
set background(value: boolean) {
|
|
279
|
-
this
|
|
280
|
-
this
|
|
281
|
-
this
|
|
265
|
+
this._background = value;
|
|
266
|
+
this.reflectBooleanAttribute("background", value);
|
|
267
|
+
this.updateBackgroundMode();
|
|
282
268
|
}
|
|
283
269
|
|
|
284
270
|
/**
|
|
@@ -296,22 +282,13 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
296
282
|
}
|
|
297
283
|
}
|
|
298
284
|
|
|
299
|
-
#reflectBooleanAttribute(name: string, value: boolean) {
|
|
300
|
-
this.updatingAttribute = true;
|
|
301
|
-
if (value) {
|
|
302
|
-
this.setAttribute(name, "");
|
|
303
|
-
} else {
|
|
304
|
-
this.removeAttribute(name);
|
|
305
|
-
}
|
|
306
|
-
this.updatingAttribute = false;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
285
|
private extractVideoId(url: string): string {
|
|
310
286
|
if (!url) return "";
|
|
311
287
|
|
|
312
288
|
// Comprehensive regex to handle various YouTube URL formats
|
|
289
|
+
// Match video IDs that are typically 11 characters but allow for variation (10-12 chars)
|
|
313
290
|
const regex =
|
|
314
|
-
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{
|
|
291
|
+
/(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{10,12})/;
|
|
315
292
|
const match = url.match(regex);
|
|
316
293
|
|
|
317
294
|
if (match && match[1]) {
|
|
@@ -319,7 +296,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
319
296
|
}
|
|
320
297
|
|
|
321
298
|
// If it's already just an ID
|
|
322
|
-
if (/^[a-zA-Z0-9_-]{
|
|
299
|
+
if (/^[a-zA-Z0-9_-]{10,12}$/.test(url)) {
|
|
323
300
|
return url;
|
|
324
301
|
}
|
|
325
302
|
|
|
@@ -327,28 +304,59 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
327
304
|
return "";
|
|
328
305
|
}
|
|
329
306
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
307
|
+
/**
|
|
308
|
+
* Sanitize video ID to prevent XSS attacks
|
|
309
|
+
*/
|
|
310
|
+
private sanitizeVideoId(videoId: string): string {
|
|
311
|
+
if (!videoId) return "";
|
|
312
|
+
|
|
313
|
+
// YouTube video IDs should only contain alphanumeric characters, hyphens, and underscores
|
|
314
|
+
// and be 10-12 characters long
|
|
315
|
+
if (/^[a-zA-Z0-9_-]{10,12}$/.test(videoId)) {
|
|
316
|
+
return videoId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.warn(`YouTubeEmbed: Invalid video ID format "${videoId}"`);
|
|
320
|
+
return "";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Implement abstract methods from BaseVideoEmbed
|
|
324
|
+
protected getComponentName(): string {
|
|
325
|
+
return "YouTubeEmbed";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
protected handlePlay(): void {
|
|
329
|
+
if (this.player && typeof this.player.playVideo === "function") {
|
|
330
|
+
this.player.playVideo();
|
|
333
331
|
}
|
|
334
332
|
}
|
|
335
333
|
|
|
336
|
-
|
|
337
|
-
|
|
334
|
+
protected handlePause(): void {
|
|
335
|
+
if (this.player && typeof this.player.pauseVideo === "function") {
|
|
336
|
+
this.player.pauseVideo();
|
|
337
|
+
}
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
-
|
|
341
|
-
|
|
340
|
+
protected override handleRetry(): void {
|
|
341
|
+
this.apiLoadRetries = 0;
|
|
342
|
+
if (this.#videoId) {
|
|
343
|
+
this.initializePlayer(this.#videoId);
|
|
344
|
+
}
|
|
342
345
|
}
|
|
343
346
|
|
|
344
|
-
|
|
345
|
-
this.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
347
|
+
protected destroyPlayer(): void {
|
|
348
|
+
if (this.player) {
|
|
349
|
+
try {
|
|
350
|
+
this.player.destroy();
|
|
351
|
+
} catch (e) {
|
|
352
|
+
this.warn("Error destroying player:", e);
|
|
353
|
+
}
|
|
354
|
+
this.player = null;
|
|
355
|
+
}
|
|
356
|
+
if (this.iframe) {
|
|
357
|
+
this.iframe.remove();
|
|
358
|
+
this.iframe = null;
|
|
359
|
+
}
|
|
352
360
|
}
|
|
353
361
|
|
|
354
362
|
private isValidPosterUrl(url: string): boolean {
|
|
@@ -362,91 +370,6 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
362
370
|
}
|
|
363
371
|
}
|
|
364
372
|
|
|
365
|
-
/**
|
|
366
|
-
* Setup fullscreen change event listener
|
|
367
|
-
*/
|
|
368
|
-
private setupFullscreenListener(): void {
|
|
369
|
-
const handleFullscreenChange = () => {
|
|
370
|
-
const isFullscreen = this.isFullscreen();
|
|
371
|
-
this.dispatchCustomEvent("fullscreenchange", { isFullscreen });
|
|
372
|
-
|
|
373
|
-
if (isFullscreen) {
|
|
374
|
-
this.classList.add("is-fullscreen");
|
|
375
|
-
} else {
|
|
376
|
-
this.classList.remove("is-fullscreen");
|
|
377
|
-
}
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
|
381
|
-
document.addEventListener("webkitfullscreenchange", handleFullscreenChange);
|
|
382
|
-
document.addEventListener("mozfullscreenchange", handleFullscreenChange);
|
|
383
|
-
document.addEventListener("MSFullscreenChange", handleFullscreenChange);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* Setup keyboard event handlers for accessibility
|
|
388
|
-
*/
|
|
389
|
-
private setupKeyboardHandlers(): void {
|
|
390
|
-
this.keyboardHandler = (e: KeyboardEvent) => {
|
|
391
|
-
// Only handle if player is ready and not in input element
|
|
392
|
-
if (!this.playerReady || (e.target as HTMLElement)?.tagName === "INPUT") {
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
switch (e.key.toLowerCase()) {
|
|
397
|
-
case "k":
|
|
398
|
-
case " ":
|
|
399
|
-
// Play/Pause
|
|
400
|
-
e.preventDefault();
|
|
401
|
-
this.togglePlay();
|
|
402
|
-
this.announceToScreenReader(
|
|
403
|
-
this.#playing ? "Video playing" : "Video paused",
|
|
404
|
-
);
|
|
405
|
-
break;
|
|
406
|
-
|
|
407
|
-
case "m":
|
|
408
|
-
// Mute/Unmute
|
|
409
|
-
e.preventDefault();
|
|
410
|
-
this.toggleMute();
|
|
411
|
-
this.player?.isMuted().then((muted: boolean) => {
|
|
412
|
-
this.announceToScreenReader(
|
|
413
|
-
muted ? "Video muted" : "Video unmuted",
|
|
414
|
-
);
|
|
415
|
-
});
|
|
416
|
-
break;
|
|
417
|
-
|
|
418
|
-
case "f":
|
|
419
|
-
// Fullscreen toggle
|
|
420
|
-
e.preventDefault();
|
|
421
|
-
if (document.fullscreenElement) {
|
|
422
|
-
document.exitFullscreen();
|
|
423
|
-
this.announceToScreenReader("Exited fullscreen");
|
|
424
|
-
} else if (this.enterFullscreen) {
|
|
425
|
-
this.enterFullscreen();
|
|
426
|
-
this.announceToScreenReader("Entered fullscreen");
|
|
427
|
-
}
|
|
428
|
-
break;
|
|
429
|
-
}
|
|
430
|
-
};
|
|
431
|
-
|
|
432
|
-
this.addEventListener("keydown", this.keyboardHandler as EventListener);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Announce message to screen readers
|
|
437
|
-
*/
|
|
438
|
-
private announceToScreenReader(message: string): void {
|
|
439
|
-
if (this.ariaLiveRegion) {
|
|
440
|
-
this.ariaLiveRegion.textContent = message;
|
|
441
|
-
// Clear after a brief delay
|
|
442
|
-
setTimeout(() => {
|
|
443
|
-
if (this.ariaLiveRegion) {
|
|
444
|
-
this.ariaLiveRegion.textContent = "";
|
|
445
|
-
}
|
|
446
|
-
}, 1000);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
373
|
/**
|
|
451
374
|
* Add preconnect and dns-prefetch hints for YouTube domains
|
|
452
375
|
*/
|
|
@@ -472,52 +395,65 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
472
395
|
}
|
|
473
396
|
|
|
474
397
|
/**
|
|
475
|
-
*
|
|
398
|
+
* Clear content while preserving accessibility elements
|
|
476
399
|
*/
|
|
477
|
-
private
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
400
|
+
private clearContent(): void {
|
|
401
|
+
// Temporarily remove and preserve ariaLiveRegion
|
|
402
|
+
const preservedAriaLive = this.ariaLiveRegion;
|
|
403
|
+
if (preservedAriaLive && preservedAriaLive.parentNode === this) {
|
|
404
|
+
this.removeChild(preservedAriaLive);
|
|
481
405
|
}
|
|
482
406
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
rootMargin: "50px", // Start loading 50px before entering viewport
|
|
486
|
-
threshold: 0.01,
|
|
487
|
-
};
|
|
488
|
-
|
|
489
|
-
this.intersectionObserver = new IntersectionObserver((entries) => {
|
|
490
|
-
entries.forEach((entry) => {
|
|
491
|
-
if (entry.isIntersecting && !this.hasLoadedVideo) {
|
|
492
|
-
this.hasLoadedVideo = true;
|
|
493
|
-
this.log("YouTubeEmbed: Video entering viewport, loading...");
|
|
494
|
-
|
|
495
|
-
// Check if poster was clicked before intersection
|
|
496
|
-
const shouldAutoplay =
|
|
497
|
-
this.getAttribute("data-poster-autoplay") === "true";
|
|
407
|
+
// Clear all content
|
|
408
|
+
this.innerHTML = "";
|
|
498
409
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
410
|
+
// Re-append preserved elements
|
|
411
|
+
if (preservedAriaLive) {
|
|
412
|
+
this.appendChild(preservedAriaLive);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
503
415
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
416
|
+
/**
|
|
417
|
+
* Ensure aria-live region is present in DOM
|
|
418
|
+
*/
|
|
419
|
+
private ensureAriaLiveRegion(): void {
|
|
420
|
+
if (this.ariaLiveRegion && !this.contains(this.ariaLiveRegion)) {
|
|
421
|
+
this.appendChild(this.ariaLiveRegion);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
511
424
|
|
|
512
|
-
|
|
425
|
+
/**
|
|
426
|
+
* Set up accessibility features
|
|
427
|
+
*/
|
|
428
|
+
private setupAccessibility(): void {
|
|
429
|
+
// Create screen reader announcements container
|
|
430
|
+
if (!this.ariaLiveRegion) {
|
|
431
|
+
this.ariaLiveRegion = document.createElement("div");
|
|
432
|
+
this.ariaLiveRegion.setAttribute("role", "status");
|
|
433
|
+
this.ariaLiveRegion.setAttribute("aria-live", "polite");
|
|
434
|
+
this.ariaLiveRegion.setAttribute("aria-atomic", "true");
|
|
435
|
+
this.ariaLiveRegion.style.position = "absolute";
|
|
436
|
+
this.ariaLiveRegion.style.left = "-10000px";
|
|
437
|
+
this.ariaLiveRegion.style.width = "1px";
|
|
438
|
+
this.ariaLiveRegion.style.height = "1px";
|
|
439
|
+
this.ariaLiveRegion.style.overflow = "hidden";
|
|
440
|
+
this.appendChild(this.ariaLiveRegion);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Set up ARIA label
|
|
444
|
+
this.setAttribute("role", "region");
|
|
445
|
+
this.setAttribute("aria-label", "YouTube video player");
|
|
513
446
|
}
|
|
514
447
|
|
|
515
448
|
/**
|
|
516
449
|
* Display error message to user with retry option
|
|
517
450
|
*/
|
|
518
|
-
|
|
451
|
+
protected override showErrorMessage(
|
|
452
|
+
message: string,
|
|
453
|
+
errorClass?: string,
|
|
454
|
+
): void {
|
|
519
455
|
const errorDiv = document.createElement("div");
|
|
520
|
-
errorDiv.className = "youtube-error-message";
|
|
456
|
+
errorDiv.className = errorClass || "youtube-error-message";
|
|
521
457
|
errorDiv.setAttribute("role", "alert");
|
|
522
458
|
errorDiv.innerHTML = `
|
|
523
459
|
<div class="error-content">
|
|
@@ -542,24 +478,18 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
542
478
|
});
|
|
543
479
|
}
|
|
544
480
|
|
|
545
|
-
// Clear existing content
|
|
546
|
-
this.
|
|
481
|
+
// Clear existing content while preserving accessibility elements
|
|
482
|
+
this.clearContent();
|
|
547
483
|
this.appendChild(errorDiv);
|
|
548
484
|
}
|
|
549
485
|
|
|
550
|
-
connectedCallback() {
|
|
551
|
-
|
|
552
|
-
this.log(
|
|
553
|
-
"YouTubeEmbed: connectedCallback called, instance count:",
|
|
554
|
-
YouTubeEmbed.instanceCount,
|
|
555
|
-
);
|
|
486
|
+
override connectedCallback() {
|
|
487
|
+
super.connectedCallback();
|
|
556
488
|
|
|
557
|
-
// Add resource hints on first
|
|
558
|
-
|
|
559
|
-
this.addResourceHints();
|
|
560
|
-
}
|
|
489
|
+
// Add resource hints on first connection
|
|
490
|
+
this.addResourceHints();
|
|
561
491
|
|
|
562
|
-
// Initialize attributes
|
|
492
|
+
// Initialize YouTube-specific attributes
|
|
563
493
|
const urlAttr = this.getAttribute("url");
|
|
564
494
|
const videoIdAttr = this.getAttribute("video-id");
|
|
565
495
|
|
|
@@ -567,18 +497,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
567
497
|
this.#url = urlAttr;
|
|
568
498
|
this.#videoId = this.extractVideoId(urlAttr);
|
|
569
499
|
} else if (videoIdAttr) {
|
|
570
|
-
this.#videoId = videoIdAttr;
|
|
500
|
+
this.#videoId = this.sanitizeVideoId(videoIdAttr);
|
|
571
501
|
}
|
|
572
502
|
|
|
573
|
-
//
|
|
574
|
-
this.#autoplay = this.hasAttribute("autoplay");
|
|
575
|
-
this.#controls = this.hasAttribute("controls");
|
|
576
|
-
this.#lazy = this.hasAttribute("lazy");
|
|
577
|
-
this.#muted = this.hasAttribute("muted");
|
|
578
|
-
this.#background = this.hasAttribute("background");
|
|
579
|
-
|
|
580
|
-
// Set string attributes
|
|
581
|
-
this.#poster = this.getAttribute("poster") || "";
|
|
503
|
+
// Parse player vars JSON
|
|
582
504
|
try {
|
|
583
505
|
const playerVarsAttr = this.getAttribute("player-vars");
|
|
584
506
|
this.#playerVars = playerVarsAttr ? JSON.parse(playerVarsAttr) : {};
|
|
@@ -595,53 +517,30 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
595
517
|
return;
|
|
596
518
|
}
|
|
597
519
|
|
|
598
|
-
//
|
|
520
|
+
// Add wrapper class when video ID is present
|
|
599
521
|
this.classList.add("youtube-embed-wrapper");
|
|
600
522
|
|
|
601
|
-
//
|
|
602
|
-
this.
|
|
603
|
-
this.setAttribute("aria-label", "Video player");
|
|
604
|
-
if (!this.hasAttribute("tabindex")) {
|
|
605
|
-
this.setAttribute("tabindex", "0");
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Create ARIA live region for screen reader announcements
|
|
609
|
-
this.ariaLiveRegion = document.createElement("div");
|
|
610
|
-
this.ariaLiveRegion.setAttribute("aria-live", "polite");
|
|
611
|
-
this.ariaLiveRegion.setAttribute("aria-atomic", "true");
|
|
612
|
-
this.ariaLiveRegion.className = "sr-only";
|
|
613
|
-
this.ariaLiveRegion.style.cssText =
|
|
614
|
-
"position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;";
|
|
615
|
-
this.appendChild(this.ariaLiveRegion);
|
|
523
|
+
// Set up accessibility features
|
|
524
|
+
this.setupAccessibility();
|
|
616
525
|
|
|
617
|
-
|
|
618
|
-
this.setupKeyboardHandlers();
|
|
619
|
-
|
|
620
|
-
// Setup fullscreen change listener
|
|
621
|
-
this.setupFullscreenListener();
|
|
622
|
-
|
|
623
|
-
this.#updateBackgroundMode();
|
|
624
|
-
|
|
625
|
-
if (this.#lazy) {
|
|
526
|
+
if (this._lazy) {
|
|
626
527
|
this.showPoster(this.#videoId);
|
|
627
|
-
|
|
628
|
-
|
|
528
|
+
this.setupLazyLoading(() => {
|
|
529
|
+
const shouldAutoplay =
|
|
530
|
+
this.getAttribute("data-poster-autoplay") === "true";
|
|
531
|
+
if (!shouldAutoplay) {
|
|
532
|
+
this.initializePlayer(this.#videoId);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
629
535
|
} else {
|
|
630
|
-
// Initialize the player immediately
|
|
631
536
|
this.initializePlayer(this.#videoId);
|
|
632
537
|
}
|
|
633
|
-
this.initialized = true;
|
|
634
|
-
this.dispatchCustomEvent("connected");
|
|
635
538
|
}
|
|
636
539
|
|
|
637
|
-
disconnectedCallback() {
|
|
638
|
-
|
|
639
|
-
this.log(
|
|
640
|
-
"YouTubeEmbed: disconnectedCallback called, remaining instances:",
|
|
641
|
-
YouTubeEmbed.instanceCount,
|
|
642
|
-
);
|
|
540
|
+
override disconnectedCallback() {
|
|
541
|
+
super.disconnectedCallback();
|
|
643
542
|
|
|
644
|
-
//
|
|
543
|
+
// Cleanup YouTube-specific resources
|
|
645
544
|
if (this.player) {
|
|
646
545
|
try {
|
|
647
546
|
this.player.destroy();
|
|
@@ -652,71 +551,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
652
551
|
this.player = null;
|
|
653
552
|
}
|
|
654
553
|
|
|
655
|
-
// Remove keyboard event handlers
|
|
656
|
-
if (this.keyboardHandler) {
|
|
657
|
-
this.removeEventListener(
|
|
658
|
-
"keydown",
|
|
659
|
-
this.keyboardHandler as EventListener,
|
|
660
|
-
);
|
|
661
|
-
this.keyboardHandler = null;
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
// Remove event listeners from poster and play button
|
|
665
|
-
const poster = this.querySelector(".youtube-poster");
|
|
666
|
-
const buttonOverlay = this.querySelector(".button-overlay");
|
|
667
|
-
|
|
668
|
-
if (poster && this.posterClickHandler) {
|
|
669
|
-
poster.removeEventListener("click", this.posterClickHandler);
|
|
670
|
-
this.posterClickHandler = null;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (buttonOverlay && this.posterClickHandler) {
|
|
674
|
-
buttonOverlay.removeEventListener("click", this.posterClickHandler);
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// Clear element content
|
|
678
|
-
this.innerHTML = "";
|
|
679
|
-
this.classList.remove(
|
|
680
|
-
"youtube-embed-wrapper",
|
|
681
|
-
"youtube-embed-container",
|
|
682
|
-
"is-playing",
|
|
683
|
-
);
|
|
684
|
-
|
|
685
|
-
// Only clean up global resources if this is the last instance
|
|
686
|
-
if (YouTubeEmbed.instanceCount === 0) {
|
|
687
|
-
this.log("YouTubeEmbed: Last instance, cleaning up global resources");
|
|
688
|
-
|
|
689
|
-
// Remove the YouTube API script
|
|
690
|
-
const script = document.querySelector(
|
|
691
|
-
'script[src="https://www.youtube.com/iframe_api"]',
|
|
692
|
-
);
|
|
693
|
-
if (script) {
|
|
694
|
-
script.remove();
|
|
695
|
-
this.log("YouTubeEmbed: YouTube API script removed.");
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Clear global references
|
|
699
|
-
if (window.onYouTubeIframeAPIReady) {
|
|
700
|
-
delete window.onYouTubeIframeAPIReady;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// Reset static flags
|
|
704
|
-
YouTubeEmbed.apiLoaded = false;
|
|
705
|
-
YouTubeEmbed.apiReady = false;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// Nullify DOM references
|
|
709
554
|
this.iframe = null;
|
|
710
|
-
this.playPauseButton = null;
|
|
711
|
-
this.setCustomControlState = null;
|
|
712
|
-
|
|
713
|
-
// Dispatch disconnected event
|
|
714
|
-
this.dispatchCustomEvent("disconnected");
|
|
715
|
-
|
|
716
|
-
this.log("YouTubeEmbed: Cleanup complete.");
|
|
717
555
|
}
|
|
718
556
|
|
|
719
|
-
|
|
557
|
+
protected reinitializePlayer() {
|
|
720
558
|
if (!this.initialized || !this.#videoId) {
|
|
721
559
|
return;
|
|
722
560
|
}
|
|
@@ -731,7 +569,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
731
569
|
}
|
|
732
570
|
|
|
733
571
|
// Clear and reinitialize
|
|
734
|
-
if (this
|
|
572
|
+
if (this._lazy) {
|
|
735
573
|
this.showPoster(this.#videoId);
|
|
736
574
|
} else {
|
|
737
575
|
this.initializePlayer(this.#videoId);
|
|
@@ -740,14 +578,14 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
740
578
|
|
|
741
579
|
private showPoster(videoId: string) {
|
|
742
580
|
// Use custom poster if valid, otherwise use YouTube thumbnail
|
|
743
|
-
const thumbnailUrl = this.isValidPosterUrl(this
|
|
744
|
-
? this
|
|
581
|
+
const thumbnailUrl = this.isValidPosterUrl(this._poster)
|
|
582
|
+
? this._poster
|
|
745
583
|
: `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
|
746
584
|
|
|
747
585
|
this.log("YouTubeEmbed: Using poster URL:", thumbnailUrl);
|
|
748
586
|
|
|
749
|
-
// Clear any existing content and event listeners
|
|
750
|
-
this.
|
|
587
|
+
// Clear any existing content and event listeners while preserving accessibility elements
|
|
588
|
+
this.clearContent();
|
|
751
589
|
|
|
752
590
|
// Clear old event listeners if they exist
|
|
753
591
|
if (this.posterClickHandler) {
|
|
@@ -820,10 +658,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
820
658
|
this.log("YouTubeEmbed: Poster displayed for lazy loading");
|
|
821
659
|
}
|
|
822
660
|
|
|
823
|
-
|
|
661
|
+
protected async initializePlayer(videoId: string) {
|
|
824
662
|
this.log("YouTubeEmbed: Initializing player for video ID:", videoId);
|
|
825
663
|
|
|
826
|
-
const isBackground = this
|
|
664
|
+
const isBackground = this._background;
|
|
827
665
|
// Background videos must autoplay and be muted, with no controls.
|
|
828
666
|
const autoplay =
|
|
829
667
|
isBackground ||
|
|
@@ -849,8 +687,8 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
849
687
|
);
|
|
850
688
|
|
|
851
689
|
// Update internal muted state if autoplay forces muting
|
|
852
|
-
if (autoplay && !this
|
|
853
|
-
this
|
|
690
|
+
if (autoplay && !this._muted) {
|
|
691
|
+
this._muted = true;
|
|
854
692
|
this.log(
|
|
855
693
|
"YouTubeEmbed: Autoplay enabled, forcing muted state for compliance",
|
|
856
694
|
);
|
|
@@ -858,16 +696,15 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
858
696
|
|
|
859
697
|
// Create a container div for the YouTube player
|
|
860
698
|
const playerContainer = document.createElement("div");
|
|
861
|
-
|
|
862
|
-
playerContainer.style.top = "0";
|
|
863
|
-
playerContainer.style.left = "0";
|
|
864
|
-
playerContainer.style.width = "100%";
|
|
865
|
-
playerContainer.style.height = "100%";
|
|
699
|
+
// Let CSS handle positioning and sizing
|
|
866
700
|
|
|
867
701
|
this.replaceChildren(playerContainer);
|
|
868
702
|
|
|
703
|
+
// Ensure aria-live region persists after replaceChildren
|
|
704
|
+
this.ensureAriaLiveRegion();
|
|
705
|
+
|
|
869
706
|
if (!controls) {
|
|
870
|
-
this.addCustomControls();
|
|
707
|
+
this.addCustomControls("youtube-embed-container");
|
|
871
708
|
}
|
|
872
709
|
|
|
873
710
|
// Build playerVars object for YouTube Player API
|
|
@@ -881,7 +718,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
881
718
|
};
|
|
882
719
|
|
|
883
720
|
// Only add mute parameter when muting
|
|
884
|
-
if (mute || this
|
|
721
|
+
if (mute || this._muted) {
|
|
885
722
|
playerVars.mute = 1;
|
|
886
723
|
}
|
|
887
724
|
|
|
@@ -910,13 +747,26 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
910
747
|
// Get the iframe element that YT.Player created
|
|
911
748
|
this.iframe = this.player.getIframe();
|
|
912
749
|
|
|
913
|
-
//
|
|
914
|
-
|
|
915
|
-
|
|
750
|
+
// Set aspect ratio (YouTube is typically 16:9)
|
|
751
|
+
this.setAspectRatio(16, 9);
|
|
752
|
+
|
|
753
|
+
// Apply styling to iframe
|
|
754
|
+
if (this.iframe) {
|
|
755
|
+
if (this._background) {
|
|
756
|
+
// In background mode, let CSS handle sizing with aspect-ratio
|
|
757
|
+
this.iframe.style.pointerEvents = "none";
|
|
758
|
+
// Remove any inline sizing to let CSS aspect-ratio work
|
|
759
|
+
this.iframe.style.removeProperty("width");
|
|
760
|
+
this.iframe.style.removeProperty("height");
|
|
761
|
+
} else {
|
|
762
|
+
// In normal mode, fill the container
|
|
763
|
+
this.iframe.style.width = "100%";
|
|
764
|
+
this.iframe.style.height = "100%";
|
|
765
|
+
}
|
|
916
766
|
}
|
|
917
767
|
|
|
918
768
|
// Sync muted state with YouTube player
|
|
919
|
-
if (this
|
|
769
|
+
if (this._muted) {
|
|
920
770
|
this.player.mute();
|
|
921
771
|
} else {
|
|
922
772
|
this.player.unMute();
|
|
@@ -925,7 +775,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
925
775
|
// Check if autoplay was enabled during initialization and update custom control state
|
|
926
776
|
if (autoplay && this.setCustomControlState) {
|
|
927
777
|
this.setCustomControlState(true);
|
|
928
|
-
this
|
|
778
|
+
this._playing = true;
|
|
929
779
|
}
|
|
930
780
|
|
|
931
781
|
this.dispatchCustomEvent("ready");
|
|
@@ -935,7 +785,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
935
785
|
const isPaused = event.data === YT.PlayerState.PAUSED;
|
|
936
786
|
const isEnded = event.data === YT.PlayerState.ENDED;
|
|
937
787
|
|
|
938
|
-
this
|
|
788
|
+
this._playing = isPlaying;
|
|
939
789
|
|
|
940
790
|
// Sync muted state with actual player state (only if not programmatically updating)
|
|
941
791
|
try {
|
|
@@ -945,8 +795,8 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
945
795
|
typeof this.player.isMuted === "function"
|
|
946
796
|
) {
|
|
947
797
|
const actualMuted = this.player.isMuted();
|
|
948
|
-
if (actualMuted !== this
|
|
949
|
-
this
|
|
798
|
+
if (actualMuted !== this._muted) {
|
|
799
|
+
this._muted = actualMuted;
|
|
950
800
|
this.log("YouTubeEmbed: Synced muted state:", actualMuted);
|
|
951
801
|
}
|
|
952
802
|
}
|
|
@@ -997,6 +847,17 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
997
847
|
code: event.data,
|
|
998
848
|
});
|
|
999
849
|
},
|
|
850
|
+
onPlaybackQualityChange: (event: any) => {
|
|
851
|
+
const quality = event.data;
|
|
852
|
+
this.log("YouTubeEmbed: Quality changed to:", quality);
|
|
853
|
+
|
|
854
|
+
// Dispatch quality change event
|
|
855
|
+
this.dispatchCustomEvent("qualitychange", {
|
|
856
|
+
newQuality: quality,
|
|
857
|
+
oldQuality: this.getCurrentQuality(),
|
|
858
|
+
availableQualities: this.getAvailableQualities(),
|
|
859
|
+
});
|
|
860
|
+
},
|
|
1000
861
|
},
|
|
1001
862
|
});
|
|
1002
863
|
} catch (error) {
|
|
@@ -1005,6 +866,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1005
866
|
error instanceof Error
|
|
1006
867
|
? error.message
|
|
1007
868
|
: "Failed to load YouTube player. Please refresh the page or check your connection.",
|
|
869
|
+
"youtube-error-message",
|
|
1008
870
|
);
|
|
1009
871
|
this.dispatchCustomEvent("error", {
|
|
1010
872
|
message: error instanceof Error ? error.message : "API load failed",
|
|
@@ -1015,14 +877,21 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1015
877
|
}
|
|
1016
878
|
|
|
1017
879
|
private static async loadYouTubeAPI(): Promise<void> {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
}
|
|
880
|
+
// If API is already ready, return immediately
|
|
881
|
+
if (this.apiReady) {
|
|
882
|
+
return Promise.resolve();
|
|
883
|
+
}
|
|
1023
884
|
|
|
885
|
+
// If another instance is already loading, wait for that promise
|
|
886
|
+
if (this.apiLoadingPromise) {
|
|
887
|
+
return this.apiLoadingPromise;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Create a new loading promise that all instances will share
|
|
891
|
+
this.apiLoadingPromise = new Promise((resolve, reject) => {
|
|
1024
892
|
const timeoutId = setTimeout(() => {
|
|
1025
893
|
cleanup();
|
|
894
|
+
this.apiLoadingPromise = null;
|
|
1026
895
|
reject(
|
|
1027
896
|
new Error(
|
|
1028
897
|
"YouTube API loading timeout. The API script may be blocked by an ad blocker or network issue.",
|
|
@@ -1035,11 +904,13 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1035
904
|
delete window.onYouTubeIframeAPIReady;
|
|
1036
905
|
};
|
|
1037
906
|
|
|
907
|
+
// Only add the script if it hasn't been added yet
|
|
1038
908
|
if (!this.apiLoaded) {
|
|
1039
909
|
const script = document.createElement("script");
|
|
1040
910
|
script.src = "https://www.youtube.com/iframe_api";
|
|
1041
911
|
script.onerror = () => {
|
|
1042
912
|
cleanup();
|
|
913
|
+
this.apiLoadingPromise = null;
|
|
1043
914
|
reject(
|
|
1044
915
|
new Error(
|
|
1045
916
|
"Failed to load YouTube API script. Please check your network connection or disable ad blockers.",
|
|
@@ -1056,6 +927,8 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1056
927
|
resolve();
|
|
1057
928
|
};
|
|
1058
929
|
});
|
|
930
|
+
|
|
931
|
+
return this.apiLoadingPromise;
|
|
1059
932
|
}
|
|
1060
933
|
|
|
1061
934
|
/**
|
|
@@ -1080,8 +953,8 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1080
953
|
}
|
|
1081
954
|
}
|
|
1082
955
|
|
|
1083
|
-
|
|
1084
|
-
this.classList.add("youtube-embed-container");
|
|
956
|
+
protected override addCustomControls(containerClass: string) {
|
|
957
|
+
this.classList.add(containerClass || "youtube-embed-container");
|
|
1085
958
|
|
|
1086
959
|
// Create button overlay
|
|
1087
960
|
const buttonOverlay = document.createElement("div");
|
|
@@ -1136,26 +1009,18 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1136
1009
|
this.appendChild(buttonOverlay);
|
|
1137
1010
|
}
|
|
1138
1011
|
|
|
1139
|
-
#updateBackgroundMode() {
|
|
1140
|
-
if (this.#background) {
|
|
1141
|
-
this.classList.add("is-background");
|
|
1142
|
-
// Ensure properties for background mode are set
|
|
1143
|
-
this.#autoplay = true;
|
|
1144
|
-
this.#muted = true;
|
|
1145
|
-
this.#controls = false;
|
|
1146
|
-
this.#lazy = false; // Background videos should load immediately
|
|
1147
|
-
} else {
|
|
1148
|
-
this.classList.remove("is-background");
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
1012
|
// Update the playVideo method to wait for the player to be ready
|
|
1153
1013
|
/**
|
|
1154
1014
|
* Plays the video. If the video is lazy-loaded and not yet initialized, it will be loaded first.
|
|
1015
|
+
*
|
|
1016
|
+
* Wraps YouTube IFrame API's `playVideo()` method.
|
|
1017
|
+
* Note: Autoplay is subject to browser policies and usually requires the video to be muted.
|
|
1018
|
+
*
|
|
1019
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#playVideo
|
|
1155
1020
|
*/
|
|
1156
1021
|
public async play(): Promise<void> {
|
|
1157
1022
|
// Check if this is a lazy-loaded video that hasn't been initialized yet
|
|
1158
|
-
if (this
|
|
1023
|
+
if (this._lazy && !this.player && !this.iframe) {
|
|
1159
1024
|
this.log(
|
|
1160
1025
|
"YouTubeEmbed: Lazy video needs to be loaded first. Initializing...",
|
|
1161
1026
|
);
|
|
@@ -1203,7 +1068,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1203
1068
|
}
|
|
1204
1069
|
}
|
|
1205
1070
|
|
|
1206
|
-
this
|
|
1071
|
+
this._playing = true;
|
|
1207
1072
|
// Update custom control state
|
|
1208
1073
|
if (this.setCustomControlState) {
|
|
1209
1074
|
this.setCustomControlState(true);
|
|
@@ -1214,6 +1079,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1214
1079
|
|
|
1215
1080
|
/**
|
|
1216
1081
|
* Pauses the currently playing video.
|
|
1082
|
+
*
|
|
1083
|
+
* Wraps YouTube IFrame API's `pauseVideo()` method.
|
|
1084
|
+
*
|
|
1085
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#pauseVideo
|
|
1217
1086
|
*/
|
|
1218
1087
|
public pause() {
|
|
1219
1088
|
if (this.player && typeof this.player.pauseVideo === "function") {
|
|
@@ -1230,7 +1099,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1230
1099
|
}
|
|
1231
1100
|
}
|
|
1232
1101
|
|
|
1233
|
-
this
|
|
1102
|
+
this._playing = false;
|
|
1234
1103
|
// Update custom control state
|
|
1235
1104
|
if (this.setCustomControlState) {
|
|
1236
1105
|
this.setCustomControlState(false);
|
|
@@ -1241,6 +1110,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1241
1110
|
|
|
1242
1111
|
/**
|
|
1243
1112
|
* Stops the video and resets it to the beginning.
|
|
1113
|
+
*
|
|
1114
|
+
* Wraps YouTube IFrame API's `stopVideo()` method.
|
|
1115
|
+
*
|
|
1116
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#stopVideo
|
|
1244
1117
|
*/
|
|
1245
1118
|
public stopVideo() {
|
|
1246
1119
|
if (this.player && typeof this.player.stopVideo === "function") {
|
|
@@ -1257,7 +1130,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1257
1130
|
}
|
|
1258
1131
|
}
|
|
1259
1132
|
|
|
1260
|
-
this
|
|
1133
|
+
this._playing = false;
|
|
1261
1134
|
// Update custom control state
|
|
1262
1135
|
if (this.setCustomControlState) {
|
|
1263
1136
|
this.setCustomControlState(false);
|
|
@@ -1268,6 +1141,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1268
1141
|
|
|
1269
1142
|
/**
|
|
1270
1143
|
* Mutes the video audio.
|
|
1144
|
+
*
|
|
1145
|
+
* Wraps YouTube IFrame API's `mute()` method.
|
|
1146
|
+
*
|
|
1147
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#mute
|
|
1271
1148
|
*/
|
|
1272
1149
|
public mute() {
|
|
1273
1150
|
this.muted = true;
|
|
@@ -1275,6 +1152,10 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1275
1152
|
|
|
1276
1153
|
/**
|
|
1277
1154
|
* Unmutes the video audio.
|
|
1155
|
+
*
|
|
1156
|
+
* Wraps YouTube IFrame API's `unMute()` method.
|
|
1157
|
+
*
|
|
1158
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#unMute
|
|
1278
1159
|
*/
|
|
1279
1160
|
public unmute() {
|
|
1280
1161
|
this.muted = false;
|
|
@@ -1284,7 +1165,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1284
1165
|
* Toggles the video between playing and paused.
|
|
1285
1166
|
*/
|
|
1286
1167
|
public togglePlay() {
|
|
1287
|
-
if (this
|
|
1168
|
+
if (this._playing) {
|
|
1288
1169
|
this.pause();
|
|
1289
1170
|
} else {
|
|
1290
1171
|
this.play();
|
|
@@ -1295,21 +1176,15 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1295
1176
|
* Toggles the audio between muted and unmuted.
|
|
1296
1177
|
*/
|
|
1297
1178
|
public toggleMute() {
|
|
1298
|
-
this.muted = !this
|
|
1179
|
+
this.muted = !this._muted;
|
|
1299
1180
|
}
|
|
1300
1181
|
|
|
1301
1182
|
/**
|
|
1302
1183
|
* Toggles the debug logging for all component instances.
|
|
1303
1184
|
* @param {boolean} [forceState] - Optional: force debug mode on or off.
|
|
1304
1185
|
*/
|
|
1305
|
-
public static toggleDebug(forceState?: boolean) {
|
|
1306
|
-
|
|
1307
|
-
forceState !== undefined ? forceState : !YouTubeEmbed.DEBUG;
|
|
1308
|
-
console.log(
|
|
1309
|
-
`YouTubeEmbed: Debugging is now ${
|
|
1310
|
-
YouTubeEmbed.DEBUG ? "ENABLED" : "DISABLED"
|
|
1311
|
-
}.`,
|
|
1312
|
-
);
|
|
1186
|
+
public static override toggleDebug(forceState?: boolean) {
|
|
1187
|
+
BaseVideoEmbed.toggleDebug(forceState);
|
|
1313
1188
|
}
|
|
1314
1189
|
|
|
1315
1190
|
// Public API for getting player state
|
|
@@ -1319,13 +1194,13 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1319
1194
|
*/
|
|
1320
1195
|
public getPlayerState() {
|
|
1321
1196
|
// Get actual muted state from YouTube player if available
|
|
1322
|
-
let actualMutedState = this
|
|
1197
|
+
let actualMutedState = this._muted;
|
|
1323
1198
|
if (this.player && this.playerReady) {
|
|
1324
1199
|
try {
|
|
1325
1200
|
actualMutedState = this.player.isMuted();
|
|
1326
1201
|
// Sync internal state with actual player state
|
|
1327
|
-
if (actualMutedState !== this
|
|
1328
|
-
this
|
|
1202
|
+
if (actualMutedState !== this._muted) {
|
|
1203
|
+
this._muted = actualMutedState;
|
|
1329
1204
|
}
|
|
1330
1205
|
} catch (error) {
|
|
1331
1206
|
// Fallback to internal state if player method fails
|
|
@@ -1337,7 +1212,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1337
1212
|
}
|
|
1338
1213
|
|
|
1339
1214
|
return {
|
|
1340
|
-
playing: this
|
|
1215
|
+
playing: this._playing,
|
|
1341
1216
|
muted: actualMutedState,
|
|
1342
1217
|
videoId: this.#videoId,
|
|
1343
1218
|
ready: this.playerReady,
|
|
@@ -1351,13 +1226,17 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1351
1226
|
* @returns `true` if the video is playing.
|
|
1352
1227
|
*/
|
|
1353
1228
|
public isPlaying(): boolean {
|
|
1354
|
-
return this
|
|
1229
|
+
return this._playing;
|
|
1355
1230
|
}
|
|
1356
1231
|
|
|
1357
1232
|
// Public API for checking if video is muted
|
|
1358
1233
|
/**
|
|
1359
1234
|
* Checks if the video is currently muted.
|
|
1235
|
+
*
|
|
1236
|
+
* Wraps YouTube IFrame API's `isMuted()` method.
|
|
1237
|
+
*
|
|
1360
1238
|
* @returns `true` if the video is muted.
|
|
1239
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#isMuted
|
|
1361
1240
|
*/
|
|
1362
1241
|
public isMuted(): boolean {
|
|
1363
1242
|
if (this.player && this.playerReady) {
|
|
@@ -1370,7 +1249,7 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1370
1249
|
);
|
|
1371
1250
|
}
|
|
1372
1251
|
}
|
|
1373
|
-
return this
|
|
1252
|
+
return this._muted;
|
|
1374
1253
|
}
|
|
1375
1254
|
|
|
1376
1255
|
/**
|
|
@@ -1411,10 +1290,15 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1411
1290
|
* Toggle fullscreen mode
|
|
1412
1291
|
*/
|
|
1413
1292
|
async toggleFullscreen(): Promise<void> {
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1293
|
+
try {
|
|
1294
|
+
if (this.isFullscreen()) {
|
|
1295
|
+
await this.exitFullscreen();
|
|
1296
|
+
} else {
|
|
1297
|
+
await this.enterFullscreen();
|
|
1298
|
+
}
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
this.warn("YouTubeEmbed: Toggle fullscreen failed:", error);
|
|
1301
|
+
throw error;
|
|
1418
1302
|
}
|
|
1419
1303
|
}
|
|
1420
1304
|
|
|
@@ -1432,8 +1316,17 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1432
1316
|
}
|
|
1433
1317
|
|
|
1434
1318
|
/**
|
|
1435
|
-
* Get available quality levels for the current video
|
|
1436
|
-
*
|
|
1319
|
+
* Get available quality levels for the current video.
|
|
1320
|
+
*
|
|
1321
|
+
* @deprecated Since October 2019 - YouTube deprecated this API. This method is a no-op and returns an empty array.
|
|
1322
|
+
* YouTube now automatically controls quality based on network conditions and device capabilities.
|
|
1323
|
+
* See: https://support.google.com/youtube/answer/91449
|
|
1324
|
+
*
|
|
1325
|
+
* Wraps YouTube IFrame API's `getAvailableQualityLevels()` method (deprecated).
|
|
1326
|
+
*
|
|
1327
|
+
* @returns Array of available quality levels (always returns empty array [])
|
|
1328
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#getAvailableQualityLevels
|
|
1329
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#Revision_History (October 24, 2019)
|
|
1437
1330
|
*/
|
|
1438
1331
|
getAvailableQualities(): string[] {
|
|
1439
1332
|
if (this.player && this.playerReady) {
|
|
@@ -1447,8 +1340,17 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1447
1340
|
}
|
|
1448
1341
|
|
|
1449
1342
|
/**
|
|
1450
|
-
* Get the current playback quality
|
|
1451
|
-
*
|
|
1343
|
+
* Get the current playback quality.
|
|
1344
|
+
*
|
|
1345
|
+
* @deprecated Since October 2019 - YouTube deprecated this API. This method is a no-op and always returns "auto".
|
|
1346
|
+
* YouTube now automatically controls quality based on network conditions and device capabilities.
|
|
1347
|
+
* See: https://support.google.com/youtube/answer/91449
|
|
1348
|
+
*
|
|
1349
|
+
* Wraps YouTube IFrame API's `getPlaybackQuality()` method (deprecated).
|
|
1350
|
+
*
|
|
1351
|
+
* @returns Current quality level (always returns "auto")
|
|
1352
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#getPlaybackQuality
|
|
1353
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#Revision_History (October 24, 2019)
|
|
1452
1354
|
*/
|
|
1453
1355
|
getCurrentQuality(): string {
|
|
1454
1356
|
if (this.player && this.playerReady) {
|
|
@@ -1462,8 +1364,22 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1462
1364
|
}
|
|
1463
1365
|
|
|
1464
1366
|
/**
|
|
1465
|
-
* Set the playback quality
|
|
1466
|
-
*
|
|
1367
|
+
* Set the playback quality.
|
|
1368
|
+
*
|
|
1369
|
+
* @deprecated Since October 2019 - YouTube deprecated this API. This method is a NO-OP and has NO EFFECT.
|
|
1370
|
+
* According to YouTube's official documentation update (Oct 24, 2019), setPlaybackQuality() is now a no-op function
|
|
1371
|
+
* that does nothing. YouTube automatically controls quality based on network conditions and device capabilities.
|
|
1372
|
+
* Any calls to this method will be silently ignored by YouTube's player.
|
|
1373
|
+
* See: https://support.google.com/youtube/answer/91449
|
|
1374
|
+
*
|
|
1375
|
+
* CRITICAL: This method is kept for API compatibility only to prevent breaking existing code.
|
|
1376
|
+
* It does NOT change video quality for YouTube videos. Use Vimeo or HTML5 video if manual quality control is required.
|
|
1377
|
+
*
|
|
1378
|
+
* Wraps YouTube IFrame API's `setPlaybackQuality(suggestedQuality)` method (deprecated - no-op).
|
|
1379
|
+
*
|
|
1380
|
+
* @param quality The desired quality level (IGNORED by YouTube)
|
|
1381
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#setPlaybackQuality
|
|
1382
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#Revision_History (October 24, 2019)
|
|
1467
1383
|
*/
|
|
1468
1384
|
setQuality(quality: string): void {
|
|
1469
1385
|
if (!this.player || !this.playerReady) {
|
|
@@ -1472,20 +1388,12 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1472
1388
|
}
|
|
1473
1389
|
|
|
1474
1390
|
try {
|
|
1475
|
-
|
|
1391
|
+
// Set playback quality
|
|
1392
|
+
// Note: This is only a suggestion - YouTube may ignore it based on various factors
|
|
1476
1393
|
this.player.setPlaybackQuality(quality);
|
|
1477
|
-
|
|
1478
|
-
//
|
|
1479
|
-
|
|
1480
|
-
detail: {
|
|
1481
|
-
oldQuality,
|
|
1482
|
-
newQuality: quality,
|
|
1483
|
-
availableQualities: this.getAvailableQualities(),
|
|
1484
|
-
},
|
|
1485
|
-
bubbles: true,
|
|
1486
|
-
composed: true,
|
|
1487
|
-
});
|
|
1488
|
-
this.dispatchEvent(event);
|
|
1394
|
+
this.log("YouTubeEmbed: Quality change requested:", quality);
|
|
1395
|
+
// Note: Quality change event will be dispatched by onPlaybackQualityChange callback
|
|
1396
|
+
// if YouTube honors the request
|
|
1489
1397
|
} catch (error) {
|
|
1490
1398
|
this.warn("YouTubeEmbed: Could not set quality:", error);
|
|
1491
1399
|
}
|
|
@@ -1494,7 +1402,11 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1494
1402
|
// Public API for getting current playback time
|
|
1495
1403
|
/**
|
|
1496
1404
|
* Gets the current playback time in seconds.
|
|
1405
|
+
*
|
|
1406
|
+
* Wraps YouTube IFrame API's `getCurrentTime()` method.
|
|
1407
|
+
*
|
|
1497
1408
|
* @returns The current time in seconds.
|
|
1409
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#getCurrentTime
|
|
1498
1410
|
*/
|
|
1499
1411
|
public getCurrentTime(): number {
|
|
1500
1412
|
if (this.player && this.playerReady) {
|
|
@@ -1510,23 +1422,57 @@ export class YouTubeEmbed extends HTMLElement {
|
|
|
1510
1422
|
return 0;
|
|
1511
1423
|
}
|
|
1512
1424
|
|
|
1425
|
+
/**
|
|
1426
|
+
* Seeks to a specific time in the video.
|
|
1427
|
+
*
|
|
1428
|
+
* Wraps YouTube IFrame API's `seekTo(seconds, allowSeekAhead)` method.
|
|
1429
|
+
*
|
|
1430
|
+
* @param seconds The time in seconds to seek to.
|
|
1431
|
+
* @see https://developers.google.com/youtube/iframe_api_reference#seekTo
|
|
1432
|
+
*/
|
|
1433
|
+
public seekTo(seconds: number): void {
|
|
1434
|
+
if (this.player && this.playerReady) {
|
|
1435
|
+
try {
|
|
1436
|
+
this.player.seekTo(seconds, true);
|
|
1437
|
+
this.log("YouTubeEmbed: Seeked to:", seconds);
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
this.warn("YouTubeEmbed: Could not seek to time:", error);
|
|
1440
|
+
}
|
|
1441
|
+
} else {
|
|
1442
|
+
this.warn("YouTubeEmbed: Player not ready for seeking");
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Property getter/setter for current playback time
|
|
1448
|
+
*/
|
|
1449
|
+
get currentTime(): number {
|
|
1450
|
+
return this.getCurrentTime();
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
set currentTime(seconds: number) {
|
|
1454
|
+
this.seekTo(seconds);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1513
1457
|
// Public API for setting video by ID or URL
|
|
1514
1458
|
/**
|
|
1515
|
-
* Loads a new video by its ID or URL.
|
|
1459
|
+
* Loads a new video by its ID or URL. If no parameter is provided, loads the current video (useful for triggering lazy load).
|
|
1516
1460
|
* @param videoIdOrUrl The 11-character video ID or the full YouTube URL.
|
|
1517
1461
|
*/
|
|
1518
|
-
public loadVideo(videoIdOrUrl
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1462
|
+
public loadVideo(videoIdOrUrl?: string) {
|
|
1463
|
+
if (videoIdOrUrl) {
|
|
1464
|
+
// Determine if input is a full URL or a plain video ID
|
|
1465
|
+
const extracted = this.extractVideoId(videoIdOrUrl);
|
|
1466
|
+
if (extracted) {
|
|
1467
|
+
this.#url = videoIdOrUrl;
|
|
1468
|
+
this.#videoId = extracted;
|
|
1469
|
+
} else {
|
|
1470
|
+
this.#videoId = videoIdOrUrl;
|
|
1471
|
+
}
|
|
1526
1472
|
}
|
|
1527
1473
|
|
|
1528
1474
|
// If component is lazy, load immediately when requested
|
|
1529
|
-
if (this
|
|
1475
|
+
if (this._lazy && !this.player) {
|
|
1530
1476
|
try {
|
|
1531
1477
|
this.initializePlayer(this.#videoId);
|
|
1532
1478
|
} catch (error) {
|