@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.
Files changed (65) hide show
  1. package/README.md +322 -71
  2. package/dist/components/BaseVideoEmbed.d.ts +91 -0
  3. package/dist/components/BaseVideoEmbed.d.ts.map +1 -0
  4. package/dist/components/BaseVideoEmbed.js +275 -0
  5. package/dist/components/BaseVideoEmbed.js.map +1 -0
  6. package/dist/components/VideoEmbed.d.ts +68 -0
  7. package/dist/components/VideoEmbed.d.ts.map +1 -0
  8. package/dist/components/VideoEmbed.js +786 -0
  9. package/dist/components/VideoEmbed.js.map +1 -0
  10. package/dist/components/VimeoEmbed.d.ts +26 -36
  11. package/dist/components/VimeoEmbed.d.ts.map +1 -1
  12. package/dist/components/VimeoEmbed.js +231 -326
  13. package/dist/components/VimeoEmbed.js.map +1 -1
  14. package/dist/components/VimeoEmbed.min.js +1 -1
  15. package/dist/components/YouTubeEmbed.d.ts +108 -42
  16. package/dist/components/YouTubeEmbed.d.ts.map +1 -1
  17. package/dist/components/YouTubeEmbed.js +361 -375
  18. package/dist/components/YouTubeEmbed.js.map +1 -1
  19. package/dist/components/YouTubeEmbed.min.js +1 -1
  20. package/dist/css/components.css +285 -68
  21. package/dist/css/components.css.map +1 -1
  22. package/dist/css/components.min.css +1 -1
  23. package/dist/css/main.css +285 -68
  24. package/dist/css/main.css.map +1 -1
  25. package/dist/css/main.min.css +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/index.min.js +1 -1
  31. package/dist/types/index.d.ts +1 -0
  32. package/dist/types/index.d.ts.map +1 -1
  33. package/dist/video-only.d.ts +7 -0
  34. package/dist/video-only.d.ts.map +1 -0
  35. package/dist/video-only.js +8 -0
  36. package/dist/video-only.js.map +1 -0
  37. package/dist/vimeo-only.d.ts +2 -2
  38. package/dist/vimeo-only.d.ts.map +1 -1
  39. package/dist/vimeo-only.js +2 -2
  40. package/dist/vimeo-only.js.map +1 -1
  41. package/dist/vimeo-only.min.js +1 -1
  42. package/dist/youtube-only.d.ts +2 -2
  43. package/dist/youtube-only.d.ts.map +1 -1
  44. package/dist/youtube-only.js +2 -2
  45. package/dist/youtube-only.js.map +1 -1
  46. package/dist/youtube-only.min.js +1 -1
  47. package/package.json +6 -5
  48. package/src/components/BaseVideoEmbed.ts +335 -0
  49. package/src/components/VideoEmbed.ts +870 -0
  50. package/src/components/VideoEmbed.ts.backup +1051 -0
  51. package/src/components/VimeoEmbed.ts +258 -395
  52. package/src/components/YouTubeEmbed.ts +378 -432
  53. package/src/index.ts +1 -0
  54. package/src/styles/_embed-base.scss +275 -0
  55. package/src/styles/_shared-functions.scss +56 -0
  56. package/src/styles/components.scss +4 -3
  57. package/src/styles/main.scss +7 -5
  58. package/src/styles/video-embed.scss +55 -0
  59. package/src/styles/vimeo-embed.scss +8 -248
  60. package/src/styles/youtube-embed.scss +8 -254
  61. package/src/types/index.ts +1 -0
  62. package/src/types/video-embed.d.ts +90 -0
  63. package/src/video-only.ts +9 -0
  64. package/src/vimeo-only.ts +2 -2
  65. package/src/youtube-only.ts +2 -2
@@ -1,49 +1,24 @@
1
- export class YouTubeEmbed extends HTMLElement {
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 instanceCount = 0; // Track active instances
7
- private static DEBUG = false; // Enable/disable debug logging
8
- private playerReady = false;
9
- private playPauseButton: HTMLDivElement | null = null;
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
- this.#videoId = newValue || "";
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.#autoplay = newValue !== null;
67
+ this._autoplay = newValue !== null;
92
68
  if (
93
- !this.#playing &&
94
- this.#autoplay &&
95
- !this.#lazy &&
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.#controls = newValue !== null;
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.#lazy = newValue !== null;
84
+ this._lazy = newValue !== null;
109
85
  break;
110
86
  case "muted":
111
- this.#muted = newValue !== null;
87
+ this._muted = newValue !== null;
112
88
  if (this.player && this.playerReady) {
113
- if (this.#muted) {
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.#poster = newValue || "";
122
- if (this.#lazy && !this.player) {
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.#background = newValue !== null;
128
- this.#updateBackgroundMode();
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.#autoplay;
160
+ return this._autoplay;
185
161
  }
186
162
  set autoplay(value: boolean) {
187
- this.#autoplay = value;
188
- this.#reflectBooleanAttribute("autoplay", value);
189
- if (!this.#playing && value && !this.#lazy && this.initialized) {
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.#controls;
174
+ return this._controls;
199
175
  }
200
176
  set controls(value: boolean) {
201
- this.#controls = value;
202
- this.#reflectBooleanAttribute("controls", value);
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.#lazy;
194
+ return this._lazy;
214
195
  }
215
196
  set lazy(value: boolean) {
216
- this.#lazy = value;
217
- this.#reflectBooleanAttribute("lazy", value);
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.#muted;
205
+ return this._muted;
225
206
  }
226
207
  set muted(value: boolean) {
227
- this.#muted = value;
228
- this.#reflectBooleanAttribute("muted", value);
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.#poster;
234
+ return this._poster;
249
235
  }
250
236
  set poster(value: string) {
251
- this.#poster = value;
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.#lazy && !this.player) {
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.#playing;
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.#background;
262
+ return this._background;
277
263
  }
278
264
  set background(value: boolean) {
279
- this.#background = value;
280
- this.#reflectBooleanAttribute("background", value);
281
- this.#updateBackgroundMode();
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\/)([^"&?\/ ]{11})/;
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_-]{11}$/.test(url)) {
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
- private log(...args: any[]) {
331
- if (YouTubeEmbed.DEBUG) {
332
- console.log("YouTubeEmbed:", ...args);
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
- private warn(...args: any[]) {
337
- console.warn(...args);
334
+ protected handlePause(): void {
335
+ if (this.player && typeof this.player.pauseVideo === "function") {
336
+ this.player.pauseVideo();
337
+ }
338
338
  }
339
339
 
340
- private error(...args: any[]) {
341
- console.error(...args);
340
+ protected override handleRetry(): void {
341
+ this.apiLoadRetries = 0;
342
+ if (this.#videoId) {
343
+ this.initializePlayer(this.#videoId);
344
+ }
342
345
  }
343
346
 
344
- private dispatchCustomEvent(eventName: string, detail?: any) {
345
- this.dispatchEvent(
346
- new CustomEvent(eventName, {
347
- detail,
348
- bubbles: true,
349
- composed: true,
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
- * Setup Intersection Observer for lazy loading
398
+ * Clear content while preserving accessibility elements
476
399
  */
477
- private setupIntersectionObserver(): void {
478
- if (!("IntersectionObserver" in window)) {
479
- // Fallback: load immediately if IntersectionObserver not supported
480
- return;
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
- const options: IntersectionObserverInit = {
484
- root: null,
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
- if (!shouldAutoplay) {
500
- // Just load the player, don't autoplay
501
- this.initializePlayer(this.#videoId);
502
- }
410
+ // Re-append preserved elements
411
+ if (preservedAriaLive) {
412
+ this.appendChild(preservedAriaLive);
413
+ }
414
+ }
503
415
 
504
- // Disconnect observer after loading
505
- if (this.intersectionObserver) {
506
- this.intersectionObserver.disconnect();
507
- }
508
- }
509
- });
510
- }, options);
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
- this.intersectionObserver.observe(this);
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
- private showErrorMessage(message: string): void {
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.innerHTML = "";
481
+ // Clear existing content while preserving accessibility elements
482
+ this.clearContent();
547
483
  this.appendChild(errorDiv);
548
484
  }
549
485
 
550
- connectedCallback() {
551
- YouTubeEmbed.instanceCount++;
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 instance
558
- if (YouTubeEmbed.instanceCount === 1) {
559
- this.addResourceHints();
560
- }
489
+ // Add resource hints on first connection
490
+ this.addResourceHints();
561
491
 
562
- // Initialize attributes from HTML
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
- // Set boolean attributes
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
- // Use the element itself as the container
520
+ // Add wrapper class when video ID is present
599
521
  this.classList.add("youtube-embed-wrapper");
600
522
 
601
- // Add ARIA role and tabindex for accessibility
602
- this.setAttribute("role", "region");
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
- // Setup keyboard event handlers
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
- // Setup Intersection Observer for better performance
628
- this.setupIntersectionObserver();
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
- YouTubeEmbed.instanceCount--;
639
- this.log(
640
- "YouTubeEmbed: disconnectedCallback called, remaining instances:",
641
- YouTubeEmbed.instanceCount,
642
- );
540
+ override disconnectedCallback() {
541
+ super.disconnectedCallback();
643
542
 
644
- // Stop the video and destroy the player instance
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
- private reinitializePlayer() {
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.#lazy) {
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.#poster)
744
- ? this.#poster
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.innerHTML = "";
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
- private async initializePlayer(videoId: string) {
661
+ protected async initializePlayer(videoId: string) {
824
662
  this.log("YouTubeEmbed: Initializing player for video ID:", videoId);
825
663
 
826
- const isBackground = this.#background;
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.#muted) {
853
- this.#muted = true;
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
- playerContainer.style.position = "absolute";
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.#muted) {
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
- // Apply background mode styling if needed
914
- if (this.#background && this.iframe) {
915
- this.iframe.style.pointerEvents = "none";
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.#muted) {
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.#playing = true;
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.#playing = isPlaying;
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.#muted) {
949
- this.#muted = actualMuted;
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
- return new Promise((resolve, reject) => {
1019
- if (this.apiReady) {
1020
- resolve();
1021
- return;
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
- private addCustomControls() {
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.#lazy && !this.player && !this.iframe) {
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.#playing = true;
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.#playing = false;
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.#playing = false;
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.#playing) {
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.#muted;
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
- YouTubeEmbed.DEBUG =
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.#muted;
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.#muted) {
1328
- this.#muted = actualMutedState;
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.#playing,
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.#playing;
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.#muted;
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
- if (this.isFullscreen()) {
1415
- await this.exitFullscreen();
1416
- } else {
1417
- await this.enterFullscreen();
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
- * @returns Array of available quality levels
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
- * @returns Current quality level
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
- * @param quality The desired quality level
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
- const oldQuality = this.getCurrentQuality();
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
- // Dispatch quality change event
1479
- const event = new CustomEvent("qualitychange", {
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: string) {
1519
- // Determine if input is a full URL or a plain video ID
1520
- const extracted = this.extractVideoId(videoIdOrUrl);
1521
- if (extracted) {
1522
- this.#url = videoIdOrUrl;
1523
- this.#videoId = extracted;
1524
- } else {
1525
- this.#videoId = videoIdOrUrl;
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.#lazy && !this.player) {
1475
+ if (this._lazy && !this.player) {
1530
1476
  try {
1531
1477
  this.initializePlayer(this.#videoId);
1532
1478
  } catch (error) {