@smartimpact-it/modern-video-embed 2.0.1

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 (51) hide show
  1. package/README.md +1205 -0
  2. package/dist/components/VimeoEmbed.d.ts +143 -0
  3. package/dist/components/VimeoEmbed.d.ts.map +1 -0
  4. package/dist/components/VimeoEmbed.js +1176 -0
  5. package/dist/components/VimeoEmbed.js.map +1 -0
  6. package/dist/components/VimeoEmbed.min.js +1 -0
  7. package/dist/components/YouTubeEmbed.d.ts +225 -0
  8. package/dist/components/YouTubeEmbed.d.ts.map +1 -0
  9. package/dist/components/YouTubeEmbed.js +1354 -0
  10. package/dist/components/YouTubeEmbed.js.map +1 -0
  11. package/dist/components/YouTubeEmbed.min.js +1 -0
  12. package/dist/css/components.css +349 -0
  13. package/dist/css/components.css.map +1 -0
  14. package/dist/css/components.min.css +1 -0
  15. package/dist/css/main.css +12210 -0
  16. package/dist/css/main.css.map +1 -0
  17. package/dist/css/main.min.css +7 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/index.min.js +1 -0
  23. package/dist/types/index.d.ts +7 -0
  24. package/dist/types/index.d.ts.map +1 -0
  25. package/dist/types/index.js +5 -0
  26. package/dist/types/index.js.map +1 -0
  27. package/dist/vimeo-only.d.ts +7 -0
  28. package/dist/vimeo-only.d.ts.map +1 -0
  29. package/dist/vimeo-only.js +8 -0
  30. package/dist/vimeo-only.js.map +1 -0
  31. package/dist/vimeo-only.min.js +1 -0
  32. package/dist/youtube-only.d.ts +7 -0
  33. package/dist/youtube-only.d.ts.map +1 -0
  34. package/dist/youtube-only.js +8 -0
  35. package/dist/youtube-only.js.map +1 -0
  36. package/dist/youtube-only.min.js +1 -0
  37. package/package.json +75 -0
  38. package/src/components/VimeoEmbed.ts +1340 -0
  39. package/src/components/YouTubeEmbed.ts +1568 -0
  40. package/src/index.ts +3 -0
  41. package/src/styles/README.md +56 -0
  42. package/src/styles/components.scss +7 -0
  43. package/src/styles/main.scss +10 -0
  44. package/src/styles/vimeo-embed.scss +255 -0
  45. package/src/styles/youtube-embed.scss +261 -0
  46. package/src/types/common.d.ts +198 -0
  47. package/src/types/index.ts +7 -0
  48. package/src/types/vimeo-embed.d.ts +80 -0
  49. package/src/types/youtube-embed.d.ts +83 -0
  50. package/src/vimeo-only.ts +9 -0
  51. package/src/youtube-only.ts +9 -0
@@ -0,0 +1,1568 @@
1
+ export class YouTubeEmbed extends HTMLElement {
2
+ private iframe: HTMLIFrameElement | null = null;
3
+ private player: YT["Player"] | null = null;
4
+ private static apiLoaded = false;
5
+ 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
13
+ private updatingMutedState = false; // Flag to prevent sync from overwriting programmatic mute changes
14
+
15
+ /** The full YouTube URL (e.g., "https://www.youtube.com/watch?v=...") */
16
+ #url: string = "";
17
+ /** The 11-character YouTube video ID. */
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
+ /** Extra parameters to pass to the YouTube player. */
34
+ #playerVars: Record<string, string | number | boolean> = {};
35
+
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
+ static get observedAttributes() {
48
+ return [
49
+ "url",
50
+ "video-id",
51
+ "autoplay",
52
+ "controls",
53
+ "lazy",
54
+ "muted",
55
+ "poster",
56
+ "background",
57
+ "player-vars",
58
+ "quality",
59
+ ];
60
+ }
61
+
62
+ attributeChangedCallback(
63
+ name: string,
64
+ oldValue: string | null,
65
+ newValue: string | null
66
+ ) {
67
+ // Skip processing if we're currently updating an attribute to prevent infinite loops
68
+ if (this.updatingAttribute) {
69
+ return;
70
+ }
71
+
72
+ this.log(
73
+ `YouTubeEmbed: Attribute changed - ${name}: ${oldValue} -> ${newValue}`
74
+ );
75
+
76
+ switch (name) {
77
+ case "url":
78
+ this.#url = newValue || "";
79
+ this.#videoId = this.extractVideoId(this.#url);
80
+ if (this.initialized) {
81
+ this.reinitializePlayer();
82
+ }
83
+ break;
84
+ case "video-id":
85
+ this.#videoId = newValue || "";
86
+ if (this.initialized) {
87
+ this.reinitializePlayer();
88
+ }
89
+ break;
90
+ case "autoplay":
91
+ this.#autoplay = newValue !== null;
92
+ if (
93
+ !this.#playing &&
94
+ this.#autoplay &&
95
+ !this.#lazy &&
96
+ this.initialized
97
+ ) {
98
+ this.play();
99
+ }
100
+ break;
101
+ case "controls":
102
+ this.#controls = newValue !== null;
103
+ if (this.initialized) {
104
+ this.reinitializePlayer();
105
+ }
106
+ break;
107
+ case "lazy":
108
+ this.#lazy = newValue !== null;
109
+ break;
110
+ case "muted":
111
+ this.#muted = newValue !== null;
112
+ if (this.player && this.playerReady) {
113
+ if (this.#muted) {
114
+ this.player.mute();
115
+ } else {
116
+ this.player.unMute();
117
+ }
118
+ }
119
+ break;
120
+ case "poster":
121
+ this.#poster = newValue || "";
122
+ if (this.#lazy && !this.player) {
123
+ this.showPoster(this.#videoId);
124
+ }
125
+ break;
126
+ case "background":
127
+ this.#background = newValue !== null;
128
+ this.#updateBackgroundMode();
129
+ break;
130
+ case "player-vars":
131
+ try {
132
+ this.#playerVars = newValue ? JSON.parse(newValue) : {};
133
+ } catch (e) {
134
+ this.error("YouTubeEmbed: Invalid player-vars JSON:", e);
135
+ this.#playerVars = {};
136
+ }
137
+ if (this.initialized) {
138
+ this.reinitializePlayer();
139
+ }
140
+ break;
141
+ case "quality":
142
+ if (newValue && this.player && this.playerReady) {
143
+ this.setQuality(newValue);
144
+ }
145
+ break;
146
+ }
147
+ }
148
+
149
+ // Getters and setters for attributes
150
+ /**
151
+ * Gets or sets the full YouTube URL.
152
+ * When set, it automatically extracts the video ID and reinitializes the player.
153
+ */
154
+ get url() {
155
+ return this.#url;
156
+ }
157
+ set url(value: string) {
158
+ this.#url = value;
159
+ this.#videoId = this.extractVideoId(value);
160
+ if (this.initialized) {
161
+ this.reinitializePlayer();
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Gets or sets the 11-character YouTube video ID.
167
+ * Reinitializes the player when set.
168
+ */
169
+ get videoId() {
170
+ return this.#videoId;
171
+ }
172
+ set videoId(value: string) {
173
+ this.#videoId = value;
174
+ if (this.initialized) {
175
+ this.reinitializePlayer();
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Gets or sets the autoplay state.
181
+ * Note: Autoplay is subject to browser policies and usually requires the video to be muted.
182
+ */
183
+ get autoplay() {
184
+ return this.#autoplay;
185
+ }
186
+ set autoplay(value: boolean) {
187
+ this.#autoplay = value;
188
+ this.#reflectBooleanAttribute("autoplay", value);
189
+ if (!this.#playing && value && !this.#lazy && this.initialized) {
190
+ this.play();
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Gets or sets whether native YouTube player controls are visible.
196
+ */
197
+ get controls() {
198
+ return this.#controls;
199
+ }
200
+ set controls(value: boolean) {
201
+ this.#controls = value;
202
+ this.#reflectBooleanAttribute("controls", value);
203
+ if (this.initialized) {
204
+ this.reinitializePlayer();
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Gets or sets the lazy-loading behavior.
210
+ * If true, a poster is shown, and the video loads only on user interaction.
211
+ */
212
+ get lazy() {
213
+ return this.#lazy;
214
+ }
215
+ set lazy(value: boolean) {
216
+ this.#lazy = value;
217
+ this.#reflectBooleanAttribute("lazy", value);
218
+ }
219
+
220
+ /**
221
+ * Gets or sets the muted state of the video.
222
+ */
223
+ get muted() {
224
+ return this.#muted;
225
+ }
226
+ set muted(value: boolean) {
227
+ this.#muted = value;
228
+ this.#reflectBooleanAttribute("muted", value);
229
+ if (this.player && this.playerReady) {
230
+ this.updatingMutedState = true;
231
+ if (value) {
232
+ this.player.mute();
233
+ } else {
234
+ this.player.unMute();
235
+ }
236
+ // Reset flag after a short delay to allow YouTube API to process
237
+ setTimeout(() => {
238
+ this.updatingMutedState = false;
239
+ }, 100);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Gets or sets the URL for a custom poster image.
245
+ * If not set, a default YouTube thumbnail is used for lazy-loaded videos.
246
+ */
247
+ get poster() {
248
+ return this.#poster;
249
+ }
250
+ set poster(value: string) {
251
+ this.#poster = value;
252
+ this.updatingAttribute = true;
253
+ if (value) {
254
+ this.setAttribute("poster", value);
255
+ } else {
256
+ this.removeAttribute("poster");
257
+ }
258
+ this.updatingAttribute = false;
259
+ if (this.#lazy && !this.player) {
260
+ this.showPoster(this.#videoId);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Returns `true` if the video is currently playing.
266
+ */
267
+ get playing() {
268
+ return this.#playing;
269
+ }
270
+
271
+ /**
272
+ * Gets or sets the background mode.
273
+ * In background mode, the video covers its container, is muted, and has no controls.
274
+ */
275
+ get background() {
276
+ return this.#background;
277
+ }
278
+ set background(value: boolean) {
279
+ this.#background = value;
280
+ this.#reflectBooleanAttribute("background", value);
281
+ this.#updateBackgroundMode();
282
+ }
283
+
284
+ /**
285
+ * Gets or sets extra player parameters.
286
+ * @param {Record<string, string | number | boolean>} vars - An object of key-value pairs to pass to the player.
287
+ */
288
+ get playerVars() {
289
+ return this.#playerVars;
290
+ }
291
+ set playerVars(vars: Record<string, string | number | boolean>) {
292
+ this.#playerVars = vars;
293
+ this.setAttribute("player-vars", JSON.stringify(vars));
294
+ if (this.initialized) {
295
+ this.reinitializePlayer();
296
+ }
297
+ }
298
+
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
+ private extractVideoId(url: string): string {
310
+ if (!url) return "";
311
+
312
+ // Comprehensive regex to handle various YouTube URL formats
313
+ const regex =
314
+ /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/ ]{11})/;
315
+ const match = url.match(regex);
316
+
317
+ if (match && match[1]) {
318
+ return match[1];
319
+ }
320
+
321
+ // If it's already just an ID
322
+ if (/^[a-zA-Z0-9_-]{11}$/.test(url)) {
323
+ return url;
324
+ }
325
+
326
+ this.warn(`YouTubeEmbed: Could not extract video ID from "${url}"`);
327
+ return "";
328
+ }
329
+
330
+ private log(...args: any[]) {
331
+ if (YouTubeEmbed.DEBUG) {
332
+ console.log("YouTubeEmbed:", ...args);
333
+ }
334
+ }
335
+
336
+ private warn(...args: any[]) {
337
+ console.warn(...args);
338
+ }
339
+
340
+ private error(...args: any[]) {
341
+ console.error(...args);
342
+ }
343
+
344
+ private dispatchCustomEvent(eventName: string, detail?: any) {
345
+ this.dispatchEvent(
346
+ new CustomEvent(eventName, {
347
+ detail,
348
+ bubbles: true,
349
+ composed: true,
350
+ })
351
+ );
352
+ }
353
+
354
+ private isValidPosterUrl(url: string): boolean {
355
+ if (!url) return false;
356
+ try {
357
+ new URL(url);
358
+ // Exclude known problematic domains
359
+ return true;
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
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
+ /**
451
+ * Add preconnect and dns-prefetch hints for YouTube domains
452
+ */
453
+ private addResourceHints(): void {
454
+ const hints = [
455
+ { rel: "preconnect", href: "https://www.youtube.com" },
456
+ { rel: "preconnect", href: "https://i.ytimg.com" },
457
+ { rel: "dns-prefetch", href: "https://www.youtube.com" },
458
+ { rel: "dns-prefetch", href: "https://i.ytimg.com" },
459
+ ];
460
+
461
+ hints.forEach(({ rel, href }) => {
462
+ if (!document.querySelector(`link[rel="${rel}"][href="${href}"]`)) {
463
+ const link = document.createElement("link");
464
+ link.rel = rel;
465
+ link.href = href;
466
+ if (rel === "preconnect") {
467
+ link.crossOrigin = "anonymous";
468
+ }
469
+ document.head.appendChild(link);
470
+ }
471
+ });
472
+ }
473
+
474
+ /**
475
+ * Setup Intersection Observer for lazy loading
476
+ */
477
+ private setupIntersectionObserver(): void {
478
+ if (!("IntersectionObserver" in window)) {
479
+ // Fallback: load immediately if IntersectionObserver not supported
480
+ return;
481
+ }
482
+
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";
498
+
499
+ if (!shouldAutoplay) {
500
+ // Just load the player, don't autoplay
501
+ this.initializePlayer(this.#videoId);
502
+ }
503
+
504
+ // Disconnect observer after loading
505
+ if (this.intersectionObserver) {
506
+ this.intersectionObserver.disconnect();
507
+ }
508
+ }
509
+ });
510
+ }, options);
511
+
512
+ this.intersectionObserver.observe(this);
513
+ }
514
+
515
+ /**
516
+ * Display error message to user with retry option
517
+ */
518
+ private showErrorMessage(message: string): void {
519
+ const errorDiv = document.createElement("div");
520
+ errorDiv.className = "youtube-error-message";
521
+ errorDiv.setAttribute("role", "alert");
522
+ errorDiv.innerHTML = `
523
+ <div class="error-content">
524
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
525
+ <circle cx="12" cy="12" r="10"></circle>
526
+ <line x1="12" y1="8" x2="12" y2="12"></line>
527
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
528
+ </svg>
529
+ <p class="error-message">${message}</p>
530
+ <button class="retry-button">Retry</button>
531
+ </div>
532
+ `;
533
+
534
+ const retryButton = errorDiv.querySelector(".retry-button");
535
+ if (retryButton) {
536
+ retryButton.addEventListener("click", () => {
537
+ this.apiLoadRetries = 0;
538
+ errorDiv.remove();
539
+ if (this.#videoId) {
540
+ this.initializePlayer(this.#videoId);
541
+ }
542
+ });
543
+ }
544
+
545
+ // Clear existing content
546
+ this.innerHTML = "";
547
+ this.appendChild(errorDiv);
548
+ }
549
+
550
+ connectedCallback() {
551
+ YouTubeEmbed.instanceCount++;
552
+ this.log(
553
+ "YouTubeEmbed: connectedCallback called, instance count:",
554
+ YouTubeEmbed.instanceCount
555
+ );
556
+
557
+ // Add resource hints on first instance
558
+ if (YouTubeEmbed.instanceCount === 1) {
559
+ this.addResourceHints();
560
+ }
561
+
562
+ // Initialize attributes from HTML
563
+ const urlAttr = this.getAttribute("url");
564
+ const videoIdAttr = this.getAttribute("video-id");
565
+
566
+ if (urlAttr) {
567
+ this.#url = urlAttr;
568
+ this.#videoId = this.extractVideoId(urlAttr);
569
+ } else if (videoIdAttr) {
570
+ this.#videoId = videoIdAttr;
571
+ }
572
+
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") || "";
582
+ try {
583
+ const playerVarsAttr = this.getAttribute("player-vars");
584
+ this.#playerVars = playerVarsAttr ? JSON.parse(playerVarsAttr) : {};
585
+ } catch (e) {
586
+ this.error("YouTubeEmbed: Invalid player-vars JSON on init:", e);
587
+ this.#playerVars = {};
588
+ }
589
+
590
+ if (!this.#videoId) {
591
+ this.error("YouTubeEmbed: Missing or invalid video ID");
592
+ this.dispatchCustomEvent("error", {
593
+ message: "Missing or invalid video ID",
594
+ });
595
+ return;
596
+ }
597
+
598
+ // Use the element itself as the container
599
+ this.classList.add("youtube-embed-wrapper");
600
+
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);
616
+
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) {
626
+ this.showPoster(this.#videoId);
627
+ // Setup Intersection Observer for better performance
628
+ this.setupIntersectionObserver();
629
+ } else {
630
+ // Initialize the player immediately
631
+ this.initializePlayer(this.#videoId);
632
+ }
633
+ this.initialized = true;
634
+ this.dispatchCustomEvent("connected");
635
+ }
636
+
637
+ disconnectedCallback() {
638
+ YouTubeEmbed.instanceCount--;
639
+ this.log(
640
+ "YouTubeEmbed: disconnectedCallback called, remaining instances:",
641
+ YouTubeEmbed.instanceCount
642
+ );
643
+
644
+ // Stop the video and destroy the player instance
645
+ if (this.player) {
646
+ try {
647
+ this.player.destroy();
648
+ this.log("YouTubeEmbed: Player destroyed.");
649
+ } catch (error) {
650
+ this.warn("YouTubeEmbed: Error destroying player:", error);
651
+ }
652
+ this.player = null;
653
+ }
654
+
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
+ 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
+ }
718
+
719
+ private reinitializePlayer() {
720
+ if (!this.initialized || !this.#videoId) {
721
+ return;
722
+ }
723
+
724
+ // Remove existing player
725
+ if (this.player) {
726
+ try {
727
+ this.player.destroy();
728
+ } catch (error) {
729
+ this.warn("YouTubeEmbed: Error destroying player:", error);
730
+ }
731
+ }
732
+
733
+ // Clear and reinitialize
734
+ if (this.#lazy) {
735
+ this.showPoster(this.#videoId);
736
+ } else {
737
+ this.initializePlayer(this.#videoId);
738
+ }
739
+ }
740
+
741
+ private showPoster(videoId: string) {
742
+ // Use custom poster if valid, otherwise use YouTube thumbnail
743
+ const thumbnailUrl = this.isValidPosterUrl(this.#poster)
744
+ ? this.#poster
745
+ : `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
746
+
747
+ this.log("YouTubeEmbed: Using poster URL:", thumbnailUrl);
748
+
749
+ // Clear any existing content and event listeners
750
+ this.innerHTML = "";
751
+
752
+ // Clear old event listeners if they exist
753
+ if (this.posterClickHandler) {
754
+ this.posterClickHandler = null;
755
+ }
756
+
757
+ // Create poster image
758
+ const poster = document.createElement("img");
759
+ poster.src = thumbnailUrl;
760
+ poster.alt = "YouTube Video Thumbnail";
761
+ poster.classList.add("youtube-poster", "video-poster"); // Add both classes for compatibility
762
+ poster.loading = "lazy"; // Optimize image loading
763
+
764
+ // Add error handling for poster loading
765
+ poster.onerror = () => {
766
+ this.warn("YouTubeEmbed: Poster failed to load, using white background.");
767
+ poster.style.display = "none";
768
+ this.style.backgroundColor = "#FFFFFF";
769
+ };
770
+
771
+ // Create button overlay with play button
772
+ const buttonOverlay = document.createElement("div");
773
+ buttonOverlay.classList.add("button-overlay");
774
+ buttonOverlay.setAttribute("role", "button");
775
+ buttonOverlay.setAttribute("tabindex", "0");
776
+ buttonOverlay.setAttribute("aria-label", "Play video");
777
+
778
+ const button = document.createElement("div");
779
+ button.classList.add("button");
780
+ button.setAttribute("aria-hidden", "true");
781
+
782
+ buttonOverlay.appendChild(button);
783
+
784
+ // Handle click to load video - store reference for cleanup
785
+ const loadVideo: EventListener = () => {
786
+ this.log("YouTubeEmbed: Loading video from poster click");
787
+ this.setAttribute("data-poster-autoplay", "true");
788
+ try {
789
+ this.initializePlayer(videoId);
790
+ } catch (error) {
791
+ this.error(
792
+ "YouTubeEmbed: Error initializing player from poster:",
793
+ error
794
+ );
795
+ this.dispatchCustomEvent("error", { message: "Failed to load video" });
796
+ }
797
+ };
798
+
799
+ // Store reference for cleanup
800
+ this.posterClickHandler = loadVideo;
801
+
802
+ // Add keyboard support
803
+ const keyboardActivate = (e: KeyboardEvent) => {
804
+ if (e.key === "Enter" || e.key === " ") {
805
+ e.preventDefault();
806
+ loadVideo(e);
807
+ }
808
+ };
809
+
810
+ poster.addEventListener("click", loadVideo);
811
+ buttonOverlay.addEventListener("click", loadVideo);
812
+ buttonOverlay.addEventListener(
813
+ "keydown",
814
+ keyboardActivate as EventListener
815
+ );
816
+
817
+ this.appendChild(poster);
818
+ this.appendChild(buttonOverlay);
819
+
820
+ this.log("YouTubeEmbed: Poster displayed for lazy loading");
821
+ }
822
+
823
+ private async initializePlayer(videoId: string) {
824
+ this.log("YouTubeEmbed: Initializing player for video ID:", videoId);
825
+
826
+ const isBackground = this.#background;
827
+ // Background videos must autoplay and be muted, with no controls.
828
+ const autoplay =
829
+ isBackground ||
830
+ this.hasAttribute("autoplay") ||
831
+ this.hasAttribute("data-poster-autoplay") ||
832
+ this.hasAttribute("data-should-autoplay");
833
+ const controls = isBackground ? false : this.hasAttribute("controls");
834
+ const mute = isBackground || autoplay; // Mute if background or if autoplaying
835
+
836
+ // Remove the temporary autoplay flags
837
+ if (this.hasAttribute("data-poster-autoplay")) {
838
+ this.removeAttribute("data-poster-autoplay");
839
+ }
840
+ if (this.hasAttribute("data-should-autoplay")) {
841
+ this.removeAttribute("data-should-autoplay");
842
+ }
843
+
844
+ this.log(
845
+ "YouTubeEmbed: Creating iframe with autoplay:",
846
+ autoplay,
847
+ "controls:",
848
+ controls
849
+ );
850
+
851
+ this.iframe = document.createElement("iframe");
852
+
853
+ // Build URL parameters to handle YouTube's strict policies
854
+ const params = new URLSearchParams({
855
+ enablejsapi: "1",
856
+ autoplay: autoplay ? "1" : "0",
857
+ controls: !controls && !isBackground ? "1" : "0",
858
+ mute: mute ? "1" : "0",
859
+ playsinline: "1", // Better mobile support
860
+ rel: "0", // Don't show related videos
861
+ modestbranding: "1", // Reduce YouTube branding
862
+ origin: window.location.origin || "localhost", // CORS safety
863
+ ...this.#playerVars,
864
+ });
865
+
866
+ // Add muted state if explicitly set
867
+ if (this.#muted) {
868
+ params.set("mute", "1");
869
+ }
870
+
871
+ // Update internal muted state if autoplay forces muting
872
+ if (autoplay && !this.#muted) {
873
+ this.#muted = true;
874
+ this.log(
875
+ "YouTubeEmbed: Autoplay enabled, forcing muted state for compliance"
876
+ );
877
+ }
878
+
879
+ this.iframe.src = `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
880
+ this.iframe.allow =
881
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
882
+ this.iframe.allowFullscreen = true;
883
+ this.iframe.style.position = "absolute";
884
+ this.iframe.style.border = "none";
885
+
886
+ // Disable pointer events in background mode to allow clicks to pass through
887
+ if (this.#background) {
888
+ this.iframe.style.pointerEvents = "none";
889
+ // CSS handles the sizing for background videos
890
+ } else {
891
+ this.iframe.style.top = "0";
892
+ this.iframe.style.left = "0";
893
+ this.iframe.style.width = "100%";
894
+ this.iframe.style.height = "100%";
895
+ }
896
+
897
+ this.log("YouTubeEmbed: Iframe created with src:", this.iframe.src);
898
+
899
+ this.replaceChildren(this.iframe);
900
+
901
+ if (!controls) {
902
+ this.addCustomControls();
903
+ }
904
+
905
+ // Initialize the YouTube API player for advanced control
906
+ try {
907
+ await YouTubeEmbed.loadYouTubeAPIWithRetry(this.apiLoadRetries);
908
+ this.player = new YT.Player(this.iframe, {
909
+ videoId,
910
+ events: {
911
+ onReady: () => {
912
+ this.log("YouTubeEmbed: Player is ready.");
913
+ this.playerReady = true;
914
+
915
+ // Sync muted state with YouTube player
916
+ if (this.#muted) {
917
+ this.player.mute();
918
+ } else {
919
+ this.player.unMute();
920
+ }
921
+
922
+ // Check if autoplay was enabled during initialization and update custom control state
923
+ if (autoplay && this.setCustomControlState) {
924
+ this.setCustomControlState(true);
925
+ this.#playing = true;
926
+ }
927
+
928
+ this.dispatchCustomEvent("ready");
929
+ },
930
+ onStateChange: (event: any) => {
931
+ const isPlaying = event.data === YT.PlayerState.PLAYING;
932
+ const isPaused = event.data === YT.PlayerState.PAUSED;
933
+ const isEnded = event.data === YT.PlayerState.ENDED;
934
+
935
+ this.#playing = isPlaying;
936
+
937
+ // Sync muted state with actual player state (only if not programmatically updating)
938
+ try {
939
+ if (
940
+ !this.updatingMutedState &&
941
+ this.player &&
942
+ typeof this.player.isMuted === "function"
943
+ ) {
944
+ const actualMuted = this.player.isMuted();
945
+ if (actualMuted !== this.#muted) {
946
+ this.#muted = actualMuted;
947
+ this.log("YouTubeEmbed: Synced muted state:", actualMuted);
948
+ }
949
+ }
950
+ } catch (error) {
951
+ this.warn("YouTubeEmbed: Could not sync muted state:", error);
952
+ }
953
+
954
+ // Update custom control state based on player state
955
+ if (this.setCustomControlState) {
956
+ this.setCustomControlState(isPlaying);
957
+ }
958
+
959
+ // Dispatch appropriate events
960
+ if (isPlaying) {
961
+ this.dispatchCustomEvent("play");
962
+ } else if (isPaused) {
963
+ this.dispatchCustomEvent("pause");
964
+ } else if (isEnded) {
965
+ this.dispatchCustomEvent("ended");
966
+ }
967
+ },
968
+ onError: (event: YT["OnErrorEvent"]) => {
969
+ this.error("YouTubeEmbed: Player encountered an error:", event);
970
+ let errorMessage = "Unknown error occurred";
971
+
972
+ // Handle specific YouTube error codes
973
+ switch (event.data) {
974
+ case 2:
975
+ errorMessage = "Invalid video ID";
976
+ break;
977
+ case 5:
978
+ errorMessage = "HTML5 player error";
979
+ break;
980
+ case 100:
981
+ errorMessage = "Video not found or private";
982
+ break;
983
+ case 101:
984
+ case 150:
985
+ errorMessage =
986
+ "Video cannot be embedded (restrictions by owner)";
987
+ break;
988
+ default:
989
+ errorMessage = `YouTube error code: ${event.data}`;
990
+ }
991
+
992
+ this.dispatchCustomEvent("error", {
993
+ message: errorMessage,
994
+ code: event.data,
995
+ });
996
+ },
997
+ },
998
+ });
999
+ } catch (error) {
1000
+ this.error("YouTubeEmbed: YouTube API initialization failed:", error);
1001
+ this.showErrorMessage(
1002
+ error instanceof Error
1003
+ ? error.message
1004
+ : "Failed to load YouTube player. Please refresh the page or check your connection."
1005
+ );
1006
+ this.dispatchCustomEvent("error", {
1007
+ message: error instanceof Error ? error.message : "API load failed",
1008
+ code: "API_LOAD_ERROR",
1009
+ retryable: true,
1010
+ });
1011
+ }
1012
+ }
1013
+
1014
+ private static async loadYouTubeAPI(): Promise<void> {
1015
+ return new Promise((resolve, reject) => {
1016
+ if (this.apiReady) {
1017
+ resolve();
1018
+ return;
1019
+ }
1020
+
1021
+ const timeoutId = setTimeout(() => {
1022
+ cleanup();
1023
+ reject(
1024
+ new Error(
1025
+ "YouTube API loading timeout. The API script may be blocked by an ad blocker or network issue."
1026
+ )
1027
+ );
1028
+ }, this.API_LOAD_TIMEOUT);
1029
+
1030
+ const cleanup = () => {
1031
+ clearTimeout(timeoutId);
1032
+ delete window.onYouTubeIframeAPIReady;
1033
+ };
1034
+
1035
+ if (!this.apiLoaded) {
1036
+ const script = document.createElement("script");
1037
+ script.src = "https://www.youtube.com/iframe_api";
1038
+ script.onerror = () => {
1039
+ cleanup();
1040
+ reject(
1041
+ new Error(
1042
+ "Failed to load YouTube API script. Please check your network connection or disable ad blockers."
1043
+ )
1044
+ );
1045
+ };
1046
+ document.head.appendChild(script);
1047
+ this.apiLoaded = true;
1048
+ }
1049
+
1050
+ window.onYouTubeIframeAPIReady = () => {
1051
+ this.apiReady = true;
1052
+ cleanup();
1053
+ resolve();
1054
+ };
1055
+ });
1056
+ }
1057
+
1058
+ /**
1059
+ * Load YouTube API with retry mechanism
1060
+ */
1061
+ private static async loadYouTubeAPIWithRetry(retries = 0): Promise<void> {
1062
+ try {
1063
+ await this.loadYouTubeAPI();
1064
+ } catch (error) {
1065
+ if (retries < this.MAX_API_RETRIES) {
1066
+ console.warn(
1067
+ `YouTubeEmbed: API load failed, retrying (${retries + 1}/${
1068
+ this.MAX_API_RETRIES
1069
+ })...`
1070
+ );
1071
+ await new Promise((resolve) =>
1072
+ setTimeout(resolve, this.API_RETRY_DELAY)
1073
+ );
1074
+ return this.loadYouTubeAPIWithRetry(retries + 1);
1075
+ }
1076
+ throw error;
1077
+ }
1078
+ }
1079
+
1080
+ private addCustomControls() {
1081
+ this.classList.add("youtube-embed-container");
1082
+
1083
+ // Create button overlay
1084
+ const buttonOverlay = document.createElement("div");
1085
+ buttonOverlay.classList.add("button-overlay");
1086
+
1087
+ this.playPauseButton = document.createElement("div");
1088
+ this.playPauseButton.classList.add("button");
1089
+ let isPlaying = false;
1090
+
1091
+ const setPlayingState = (playing: boolean) => {
1092
+ isPlaying = playing;
1093
+ if (playing) {
1094
+ this.classList.add("is-playing");
1095
+ } else {
1096
+ this.classList.remove("is-playing");
1097
+ }
1098
+ };
1099
+
1100
+ const togglePlayPause = () => {
1101
+ if (isPlaying) {
1102
+ // Pause the video
1103
+ if (this.player && typeof this.player.pauseVideo === "function") {
1104
+ this.player.pauseVideo();
1105
+ } else if (this.iframe && this.iframe.contentWindow) {
1106
+ this.iframe.contentWindow.postMessage(
1107
+ '{"event":"command","func":"pauseVideo","args":""}',
1108
+ "*"
1109
+ );
1110
+ }
1111
+ setPlayingState(false);
1112
+ } else {
1113
+ // Play the video
1114
+ if (this.player && typeof this.player.playVideo === "function") {
1115
+ this.player.playVideo();
1116
+ } else if (this.iframe && this.iframe.contentWindow) {
1117
+ this.iframe.contentWindow.postMessage(
1118
+ '{"event":"command","func":"playVideo","args":""}',
1119
+ "*"
1120
+ );
1121
+ }
1122
+ setPlayingState(true);
1123
+ }
1124
+ };
1125
+
1126
+ // Store reference to setPlayingState for external control
1127
+ this.setCustomControlState = setPlayingState;
1128
+
1129
+ buttonOverlay.addEventListener("click", togglePlayPause);
1130
+ if (this.playPauseButton) {
1131
+ buttonOverlay.appendChild(this.playPauseButton);
1132
+ }
1133
+ this.appendChild(buttonOverlay);
1134
+ }
1135
+
1136
+ #updateBackgroundMode() {
1137
+ if (this.#background) {
1138
+ this.classList.add("is-background");
1139
+ // Ensure properties for background mode are set
1140
+ this.#autoplay = true;
1141
+ this.#muted = true;
1142
+ this.#controls = false;
1143
+ this.#lazy = false; // Background videos should load immediately
1144
+ } else {
1145
+ this.classList.remove("is-background");
1146
+ }
1147
+ }
1148
+
1149
+ // Update the playVideo method to wait for the player to be ready
1150
+ /**
1151
+ * Plays the video. If the video is lazy-loaded and not yet initialized, it will be loaded first.
1152
+ */
1153
+ public async play(): Promise<void> {
1154
+ // Check if this is a lazy-loaded video that hasn't been initialized yet
1155
+ if (this.#lazy && !this.player && !this.iframe) {
1156
+ this.log(
1157
+ "YouTubeEmbed: Lazy video needs to be loaded first. Initializing..."
1158
+ );
1159
+ // Set a flag to auto-play after initialization
1160
+ this.setAttribute("data-should-autoplay", "true");
1161
+ // Initialize the player (this will load the video and replace the poster)
1162
+ await this.initializePlayer(this.#videoId);
1163
+ // Wait a bit for the player to be ready after initialization
1164
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1165
+ }
1166
+
1167
+ if (!this.playerReady) {
1168
+ this.warn(
1169
+ "YouTubeEmbed: Player is not ready. Waiting for initialization."
1170
+ );
1171
+ await new Promise((resolve) => {
1172
+ const interval = setInterval(() => {
1173
+ if (this.playerReady) {
1174
+ clearInterval(interval);
1175
+ resolve(null);
1176
+ }
1177
+ }, 100);
1178
+ // Timeout after 5 seconds
1179
+ setTimeout(() => {
1180
+ clearInterval(interval);
1181
+ resolve(null);
1182
+ }, 5000);
1183
+ });
1184
+ }
1185
+
1186
+ // Remove the autoplay flag if it was set
1187
+ this.removeAttribute("data-should-autoplay");
1188
+
1189
+ if (this.player && typeof this.player.playVideo === "function") {
1190
+ this.player.playVideo();
1191
+ this.log("YouTubeEmbed: Video playback started via API.");
1192
+ } else {
1193
+ this.log("YouTubeEmbed: Using iframe postMessage for play.");
1194
+ // Fallback: try to use postMessage to control iframe
1195
+ if (this.iframe && this.iframe.contentWindow) {
1196
+ this.iframe.contentWindow.postMessage(
1197
+ '{"event":"command","func":"playVideo","args":""}',
1198
+ "*"
1199
+ );
1200
+ }
1201
+ }
1202
+
1203
+ this.#playing = true;
1204
+ // Update custom control state
1205
+ if (this.setCustomControlState) {
1206
+ this.setCustomControlState(true);
1207
+ }
1208
+
1209
+ this.dispatchCustomEvent("play");
1210
+ }
1211
+
1212
+ /**
1213
+ * Pauses the currently playing video.
1214
+ */
1215
+ public pause() {
1216
+ if (this.player && typeof this.player.pauseVideo === "function") {
1217
+ this.player.pauseVideo();
1218
+ this.log("YouTubeEmbed: Video paused via API.");
1219
+ } else {
1220
+ this.log("YouTubeEmbed: Using iframe postMessage for pause.");
1221
+ // Fallback: try to use postMessage to control iframe
1222
+ if (this.iframe && this.iframe.contentWindow) {
1223
+ this.iframe.contentWindow.postMessage(
1224
+ '{"event":"command","func":"pauseVideo","args":""}',
1225
+ "*"
1226
+ );
1227
+ }
1228
+ }
1229
+
1230
+ this.#playing = false;
1231
+ // Update custom control state
1232
+ if (this.setCustomControlState) {
1233
+ this.setCustomControlState(false);
1234
+ }
1235
+
1236
+ this.dispatchCustomEvent("pause");
1237
+ }
1238
+
1239
+ /**
1240
+ * Stops the video and resets it to the beginning.
1241
+ */
1242
+ public stopVideo() {
1243
+ if (this.player && typeof this.player.stopVideo === "function") {
1244
+ this.player.stopVideo();
1245
+ this.log("YouTubeEmbed: Video stopped via API.");
1246
+ } else {
1247
+ this.log("YouTubeEmbed: Using iframe postMessage for stop.");
1248
+ // Fallback: try to use postMessage to control iframe
1249
+ if (this.iframe && this.iframe.contentWindow) {
1250
+ this.iframe.contentWindow.postMessage(
1251
+ '{"event":"command","func":"stopVideo","args":""}',
1252
+ "*"
1253
+ );
1254
+ }
1255
+ }
1256
+
1257
+ this.#playing = false;
1258
+ // Update custom control state
1259
+ if (this.setCustomControlState) {
1260
+ this.setCustomControlState(false);
1261
+ }
1262
+
1263
+ this.dispatchCustomEvent("stop");
1264
+ }
1265
+
1266
+ /**
1267
+ * Mutes the video audio.
1268
+ */
1269
+ public mute() {
1270
+ this.muted = true;
1271
+ }
1272
+
1273
+ /**
1274
+ * Unmutes the video audio.
1275
+ */
1276
+ public unmute() {
1277
+ this.muted = false;
1278
+ }
1279
+
1280
+ /**
1281
+ * Toggles the video between playing and paused.
1282
+ */
1283
+ public togglePlay() {
1284
+ if (this.#playing) {
1285
+ this.pause();
1286
+ } else {
1287
+ this.play();
1288
+ }
1289
+ }
1290
+
1291
+ /**
1292
+ * Toggles the audio between muted and unmuted.
1293
+ */
1294
+ public toggleMute() {
1295
+ this.muted = !this.#muted;
1296
+ }
1297
+
1298
+ /**
1299
+ * Toggles the debug logging for all component instances.
1300
+ * @param {boolean} [forceState] - Optional: force debug mode on or off.
1301
+ */
1302
+ public static toggleDebug(forceState?: boolean) {
1303
+ YouTubeEmbed.DEBUG =
1304
+ forceState !== undefined ? forceState : !YouTubeEmbed.DEBUG;
1305
+ console.log(
1306
+ `YouTubeEmbed: Debugging is now ${
1307
+ YouTubeEmbed.DEBUG ? "ENABLED" : "DISABLED"
1308
+ }.`
1309
+ );
1310
+ }
1311
+
1312
+ // Public API for getting player state
1313
+ /**
1314
+ * Retrieves the current state of the player.
1315
+ * @returns An object with the current player state.
1316
+ */
1317
+ public getPlayerState() {
1318
+ // Get actual muted state from YouTube player if available
1319
+ let actualMutedState = this.#muted;
1320
+ if (this.player && this.playerReady) {
1321
+ try {
1322
+ actualMutedState = this.player.isMuted();
1323
+ // Sync internal state with actual player state
1324
+ if (actualMutedState !== this.#muted) {
1325
+ this.#muted = actualMutedState;
1326
+ }
1327
+ } catch (error) {
1328
+ // Fallback to internal state if player method fails
1329
+ this.warn(
1330
+ "YouTubeEmbed: Could not get muted state from player:",
1331
+ error
1332
+ );
1333
+ }
1334
+ }
1335
+
1336
+ return {
1337
+ playing: this.#playing,
1338
+ muted: actualMutedState,
1339
+ videoId: this.#videoId,
1340
+ ready: this.playerReady,
1341
+ initialized: this.initialized,
1342
+ };
1343
+ }
1344
+
1345
+ // Public API for checking if video is playing
1346
+ /**
1347
+ * Checks if the video is currently playing.
1348
+ * @returns `true` if the video is playing.
1349
+ */
1350
+ public isPlaying(): boolean {
1351
+ return this.#playing;
1352
+ }
1353
+
1354
+ // Public API for checking if video is muted
1355
+ /**
1356
+ * Checks if the video is currently muted.
1357
+ * @returns `true` if the video is muted.
1358
+ */
1359
+ public isMuted(): boolean {
1360
+ if (this.player && this.playerReady) {
1361
+ try {
1362
+ return this.player.isMuted();
1363
+ } catch (error) {
1364
+ this.warn(
1365
+ "YouTubeEmbed: Could not get muted state from player:",
1366
+ error
1367
+ );
1368
+ }
1369
+ }
1370
+ return this.#muted;
1371
+ }
1372
+
1373
+ /**
1374
+ * Request fullscreen mode
1375
+ */
1376
+ enterFullscreen(): Promise<void> {
1377
+ const elem = this as any;
1378
+ if (elem.requestFullscreen) {
1379
+ return elem.requestFullscreen();
1380
+ } else if (elem.webkitRequestFullscreen) {
1381
+ return elem.webkitRequestFullscreen();
1382
+ } else if (elem.mozRequestFullScreen) {
1383
+ return elem.mozRequestFullScreen();
1384
+ } else if (elem.msRequestFullscreen) {
1385
+ return elem.msRequestFullscreen();
1386
+ }
1387
+ return Promise.reject(new Error("Fullscreen API not supported"));
1388
+ }
1389
+
1390
+ /**
1391
+ * Exit fullscreen mode
1392
+ */
1393
+ exitFullscreen(): Promise<void> {
1394
+ const doc = document as any;
1395
+ if (doc.exitFullscreen) {
1396
+ return doc.exitFullscreen();
1397
+ } else if (doc.webkitExitFullscreen) {
1398
+ return doc.webkitExitFullscreen();
1399
+ } else if (doc.mozCancelFullScreen) {
1400
+ return doc.mozCancelFullScreen();
1401
+ } else if (doc.msExitFullscreen) {
1402
+ return doc.msExitFullscreen();
1403
+ }
1404
+ return Promise.reject(new Error("Fullscreen API not supported"));
1405
+ }
1406
+
1407
+ /**
1408
+ * Toggle fullscreen mode
1409
+ */
1410
+ async toggleFullscreen(): Promise<void> {
1411
+ if (this.isFullscreen()) {
1412
+ await this.exitFullscreen();
1413
+ } else {
1414
+ await this.enterFullscreen();
1415
+ }
1416
+ }
1417
+
1418
+ /**
1419
+ * Check if currently in fullscreen mode
1420
+ */
1421
+ isFullscreen(): boolean {
1422
+ const doc = document as any;
1423
+ return !!(
1424
+ doc.fullscreenElement ||
1425
+ doc.webkitFullscreenElement ||
1426
+ doc.mozFullScreenElement ||
1427
+ doc.msFullscreenElement
1428
+ );
1429
+ }
1430
+
1431
+ /**
1432
+ * Get available quality levels for the current video
1433
+ * @returns Array of available quality levels
1434
+ */
1435
+ getAvailableQualities(): string[] {
1436
+ if (this.player && this.playerReady) {
1437
+ try {
1438
+ return this.player.getAvailableQualityLevels() || [];
1439
+ } catch (error) {
1440
+ this.warn("YouTubeEmbed: Could not get available qualities:", error);
1441
+ }
1442
+ }
1443
+ return [];
1444
+ }
1445
+
1446
+ /**
1447
+ * Get the current playback quality
1448
+ * @returns Current quality level
1449
+ */
1450
+ getCurrentQuality(): string {
1451
+ if (this.player && this.playerReady) {
1452
+ try {
1453
+ return this.player.getPlaybackQuality() || "auto";
1454
+ } catch (error) {
1455
+ this.warn("YouTubeEmbed: Could not get current quality:", error);
1456
+ }
1457
+ }
1458
+ return "auto";
1459
+ }
1460
+
1461
+ /**
1462
+ * Set the playback quality
1463
+ * @param quality The desired quality level
1464
+ */
1465
+ setQuality(quality: string): void {
1466
+ if (!this.player || !this.playerReady) {
1467
+ this.warn("YouTubeEmbed: Player not ready for quality change");
1468
+ return;
1469
+ }
1470
+
1471
+ try {
1472
+ const oldQuality = this.getCurrentQuality();
1473
+ this.player.setPlaybackQuality(quality);
1474
+
1475
+ // Dispatch quality change event
1476
+ const event = new CustomEvent("qualitychange", {
1477
+ detail: {
1478
+ oldQuality,
1479
+ newQuality: quality,
1480
+ availableQualities: this.getAvailableQualities(),
1481
+ },
1482
+ bubbles: true,
1483
+ composed: true,
1484
+ });
1485
+ this.dispatchEvent(event);
1486
+ } catch (error) {
1487
+ this.warn("YouTubeEmbed: Could not set quality:", error);
1488
+ }
1489
+ }
1490
+
1491
+ // Public API for getting current playback time
1492
+ /**
1493
+ * Gets the current playback time in seconds.
1494
+ * @returns The current time in seconds.
1495
+ */
1496
+ public getCurrentTime(): number {
1497
+ if (this.player && this.playerReady) {
1498
+ try {
1499
+ return this.player.getCurrentTime() || 0;
1500
+ } catch (error) {
1501
+ this.warn(
1502
+ "YouTubeEmbed: Could not get current time from player:",
1503
+ error
1504
+ );
1505
+ }
1506
+ }
1507
+ return 0;
1508
+ }
1509
+
1510
+ // Public API for setting video by ID or URL
1511
+ /**
1512
+ * Loads a new video by its ID or URL.
1513
+ * @param videoIdOrUrl The 11-character video ID or the full YouTube URL.
1514
+ */
1515
+ public loadVideo(videoIdOrUrl: string) {
1516
+ // Determine if input is a full URL or a plain video ID
1517
+ const extracted = this.extractVideoId(videoIdOrUrl);
1518
+ if (extracted) {
1519
+ this.#url = videoIdOrUrl;
1520
+ this.#videoId = extracted;
1521
+ } else {
1522
+ this.#videoId = videoIdOrUrl;
1523
+ }
1524
+
1525
+ // If component is lazy, load immediately when requested
1526
+ if (this.#lazy && !this.player) {
1527
+ try {
1528
+ this.initializePlayer(this.#videoId);
1529
+ } catch (error) {
1530
+ this.warn(
1531
+ "YouTubeEmbed: loadVideo failed to initialize player:",
1532
+ error
1533
+ );
1534
+ }
1535
+ return;
1536
+ }
1537
+
1538
+ // If already initialized, reinitialize player with the new video
1539
+ if (this.initialized) {
1540
+ this.reinitializePlayer();
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ customElements.define("youtube-embed", YouTubeEmbed);
1546
+
1547
+ declare global {
1548
+ interface Window {
1549
+ onYouTubeIframeAPIReady?: () => void;
1550
+ }
1551
+ }
1552
+
1553
+ // Add the YouTube Player API namespace declaration
1554
+ interface YT {
1555
+ Player: any;
1556
+ PlayerState: {
1557
+ PLAYING: number;
1558
+ PAUSED: number;
1559
+ ENDED: number;
1560
+ };
1561
+ OnErrorEvent: any; // Add this line
1562
+ }
1563
+
1564
+ declare const YT: YT;
1565
+
1566
+ export {};
1567
+
1568
+ // Component ready for testing