@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,1340 @@
1
+ export class VimeoEmbed extends HTMLElement {
2
+ private iframe: HTMLIFrameElement | null = null;
3
+ private player: any = null; // Vimeo Player instance
4
+ private static apiLoaded = false;
5
+ private static apiReady = false;
6
+ private static instanceCount = 0;
7
+ private static DEBUG = false;
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;
13
+ private updatingMutedState = false; // Flag to prevent sync from overwriting programmatic mute changes
14
+
15
+ /** The full Vimeo URL (e.g., "https://vimeo.com/...") */
16
+ #url: string = "";
17
+ /** The Vimeo video ID (numeric). */
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 Vimeo 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 Vimeo'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 Vimeo player. */
34
+ #playerVars: Record<string, string | number | boolean> = {};
35
+
36
+ private posterClickHandler: EventListener | null = null;
37
+ private keyboardHandler: ((e: KeyboardEvent) => void) | null = null;
38
+ private ariaLiveRegion: HTMLElement | null = null;
39
+ private apiLoadRetries = 0;
40
+ private static readonly MAX_API_RETRIES = 3;
41
+ private static readonly API_RETRY_DELAY = 2000;
42
+ private static readonly API_LOAD_TIMEOUT = 10000;
43
+ private intersectionObserver: IntersectionObserver | null = null;
44
+ private hasLoadedVideo = false;
45
+
46
+ static get observedAttributes() {
47
+ return [
48
+ "url",
49
+ "video-id",
50
+ "autoplay",
51
+ "controls",
52
+ "lazy",
53
+ "muted",
54
+ "poster",
55
+ "background",
56
+ "player-vars",
57
+ "quality",
58
+ ];
59
+ }
60
+
61
+ attributeChangedCallback(
62
+ name: string,
63
+ oldValue: string | null,
64
+ newValue: string | null
65
+ ) {
66
+ if (this.updatingAttribute) {
67
+ return;
68
+ }
69
+
70
+ this.log(
71
+ `VimeoEmbed: Attribute changed - ${name}: ${oldValue} -> ${newValue}`
72
+ );
73
+
74
+ switch (name) {
75
+ case "url":
76
+ this.#url = newValue || "";
77
+ this.#videoId = this.extractVideoId(this.#url);
78
+ if (this.initialized) {
79
+ this.reinitializePlayer();
80
+ }
81
+ break;
82
+ case "video-id":
83
+ this.#videoId = newValue || "";
84
+ if (this.initialized) {
85
+ this.reinitializePlayer();
86
+ }
87
+ break;
88
+ case "autoplay":
89
+ this.#autoplay = newValue !== null;
90
+ if (
91
+ !this.#playing &&
92
+ this.#autoplay &&
93
+ !this.#lazy &&
94
+ this.initialized
95
+ ) {
96
+ this.play();
97
+ }
98
+ break;
99
+ case "controls":
100
+ this.#controls = newValue !== null;
101
+ if (this.initialized) {
102
+ this.reinitializePlayer();
103
+ }
104
+ break;
105
+ case "lazy":
106
+ this.#lazy = newValue !== null;
107
+ break;
108
+ case "muted":
109
+ this.#muted = newValue !== null;
110
+ if (this.player && this.playerReady) {
111
+ this.player.setVolume(this.#muted ? 0 : 1);
112
+ }
113
+ break;
114
+ case "poster":
115
+ this.#poster = newValue || "";
116
+ if (this.#lazy && !this.player) {
117
+ this.showPoster(this.#videoId);
118
+ }
119
+ break;
120
+ case "background":
121
+ this.#background = newValue !== null;
122
+ this.#updateBackgroundMode();
123
+ break;
124
+ case "player-vars":
125
+ try {
126
+ this.#playerVars = newValue ? JSON.parse(newValue) : {};
127
+ } catch (e) {
128
+ this.error("VimeoEmbed: Invalid player-vars JSON:", e);
129
+ this.#playerVars = {};
130
+ }
131
+ if (this.initialized) {
132
+ this.reinitializePlayer();
133
+ }
134
+ break;
135
+ case "quality":
136
+ if (newValue && this.player && this.playerReady) {
137
+ this.setQuality(newValue);
138
+ }
139
+ break;
140
+ }
141
+ }
142
+
143
+ // Getters and setters for attributes
144
+ get url() {
145
+ return this.#url;
146
+ }
147
+ set url(value: string) {
148
+ this.#url = value;
149
+ this.#videoId = this.extractVideoId(value);
150
+ if (this.initialized) {
151
+ this.reinitializePlayer();
152
+ }
153
+ }
154
+
155
+ get videoId() {
156
+ return this.#videoId;
157
+ }
158
+ set videoId(value: string) {
159
+ this.#videoId = value;
160
+ if (this.initialized) {
161
+ this.reinitializePlayer();
162
+ }
163
+ }
164
+
165
+ get autoplay() {
166
+ return this.#autoplay;
167
+ }
168
+ set autoplay(value: boolean) {
169
+ this.#autoplay = value;
170
+ this.#reflectBooleanAttribute("autoplay", value);
171
+ if (!this.#playing && value && !this.#lazy && this.initialized) {
172
+ this.play();
173
+ }
174
+ }
175
+
176
+ get controls() {
177
+ return this.#controls;
178
+ }
179
+ set controls(value: boolean) {
180
+ this.#controls = value;
181
+ this.#reflectBooleanAttribute("controls", value);
182
+ if (this.initialized) {
183
+ this.reinitializePlayer();
184
+ }
185
+ }
186
+
187
+ get lazy() {
188
+ return this.#lazy;
189
+ }
190
+ set lazy(value: boolean) {
191
+ this.#lazy = value;
192
+ this.#reflectBooleanAttribute("lazy", value);
193
+ }
194
+
195
+ get muted() {
196
+ return this.#muted;
197
+ }
198
+ set muted(value: boolean) {
199
+ this.#muted = value;
200
+ this.#reflectBooleanAttribute("muted", value);
201
+ if (this.player && this.playerReady) {
202
+ this.updatingMutedState = true;
203
+ this.player.setVolume(value ? 0 : 1);
204
+ // Reset flag after a short delay to allow Vimeo API to process
205
+ setTimeout(() => {
206
+ this.updatingMutedState = false;
207
+ }, 100);
208
+ }
209
+ }
210
+
211
+ get poster() {
212
+ return this.#poster;
213
+ }
214
+ set poster(value: string) {
215
+ this.#poster = value;
216
+ this.updatingAttribute = true;
217
+ if (value) {
218
+ this.setAttribute("poster", value);
219
+ } else {
220
+ this.removeAttribute("poster");
221
+ }
222
+ this.updatingAttribute = false;
223
+ if (this.#lazy && !this.player) {
224
+ this.showPoster(this.#videoId);
225
+ }
226
+ }
227
+
228
+ get playing() {
229
+ return this.#playing;
230
+ }
231
+
232
+ get background() {
233
+ return this.#background;
234
+ }
235
+ set background(value: boolean) {
236
+ this.#background = value;
237
+ this.#reflectBooleanAttribute("background", value);
238
+ this.#updateBackgroundMode();
239
+ }
240
+
241
+ get playerVars() {
242
+ return this.#playerVars;
243
+ }
244
+ set playerVars(vars: Record<string, string | number | boolean>) {
245
+ this.#playerVars = vars;
246
+ this.setAttribute("player-vars", JSON.stringify(vars));
247
+ if (this.initialized) {
248
+ this.reinitializePlayer();
249
+ }
250
+ }
251
+
252
+ #reflectBooleanAttribute(name: string, value: boolean) {
253
+ this.updatingAttribute = true;
254
+ if (value) {
255
+ this.setAttribute(name, "");
256
+ } else {
257
+ this.removeAttribute(name);
258
+ }
259
+ this.updatingAttribute = false;
260
+ }
261
+
262
+ private extractVideoId(url: string): string {
263
+ if (!url) return "";
264
+
265
+ // Handle various Vimeo URL formats
266
+ // https://vimeo.com/123456789
267
+ // https://player.vimeo.com/video/123456789
268
+ const regex =
269
+ /(?:vimeo\.com\/(?:video\/)?|player\.vimeo\.com\/video\/)(\d+)/;
270
+ const match = url.match(regex);
271
+
272
+ if (match && match[1]) {
273
+ return match[1];
274
+ }
275
+
276
+ // If it's already just an ID (numeric)
277
+ if (/^\d+$/.test(url)) {
278
+ return url;
279
+ }
280
+
281
+ this.warn(`VimeoEmbed: Could not extract video ID from "${url}"`);
282
+ return "";
283
+ }
284
+
285
+ private log(...args: any[]) {
286
+ if (VimeoEmbed.DEBUG) {
287
+ console.log("VimeoEmbed:", ...args);
288
+ }
289
+ }
290
+
291
+ private warn(...args: any[]) {
292
+ console.warn(...args);
293
+ }
294
+
295
+ private error(...args: any[]) {
296
+ console.error(...args);
297
+ }
298
+
299
+ private dispatchCustomEvent(eventName: string, detail?: any) {
300
+ this.dispatchEvent(
301
+ new CustomEvent(eventName, {
302
+ detail,
303
+ bubbles: true,
304
+ composed: true,
305
+ })
306
+ );
307
+ }
308
+
309
+ private isValidPosterUrl(url: string): boolean {
310
+ if (!url) return false;
311
+ try {
312
+ new URL(url);
313
+ return true;
314
+ } catch {
315
+ return false;
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Setup fullscreen change event listener
321
+ */
322
+ private setupFullscreenListener(): void {
323
+ const handleFullscreenChange = () => {
324
+ const isFullscreen = this.isFullscreen();
325
+ this.dispatchCustomEvent("fullscreenchange", { isFullscreen });
326
+
327
+ if (isFullscreen) {
328
+ this.classList.add("is-fullscreen");
329
+ } else {
330
+ this.classList.remove("is-fullscreen");
331
+ }
332
+ };
333
+
334
+ document.addEventListener("fullscreenchange", handleFullscreenChange);
335
+ document.addEventListener("webkitfullscreenchange", handleFullscreenChange);
336
+ document.addEventListener("mozfullscreenchange", handleFullscreenChange);
337
+ document.addEventListener("MSFullscreenChange", handleFullscreenChange);
338
+ }
339
+
340
+ /**
341
+ * Setup keyboard event handlers for accessibility
342
+ */
343
+ private setupKeyboardHandlers(): void {
344
+ this.keyboardHandler = (e: KeyboardEvent) => {
345
+ // Only handle if player is ready and not in input element
346
+ if (!this.playerReady || (e.target as HTMLElement)?.tagName === "INPUT") {
347
+ return;
348
+ }
349
+
350
+ switch (e.key.toLowerCase()) {
351
+ case "k":
352
+ case " ":
353
+ // Play/Pause
354
+ e.preventDefault();
355
+ this.togglePlay();
356
+ this.announceToScreenReader(
357
+ this.#playing ? "Video playing" : "Video paused"
358
+ );
359
+ break;
360
+
361
+ case "m":
362
+ // Mute/Unmute
363
+ e.preventDefault();
364
+ this.toggleMute();
365
+ this.player?.getMuted().then((muted: boolean) => {
366
+ this.announceToScreenReader(
367
+ muted ? "Video muted" : "Video unmuted"
368
+ );
369
+ });
370
+ break;
371
+
372
+ case "f":
373
+ // Fullscreen toggle
374
+ e.preventDefault();
375
+ if (document.fullscreenElement) {
376
+ document.exitFullscreen();
377
+ this.announceToScreenReader("Exited fullscreen");
378
+ } else if (this.enterFullscreen) {
379
+ this.enterFullscreen();
380
+ this.announceToScreenReader("Entered fullscreen");
381
+ }
382
+ break;
383
+ }
384
+ };
385
+
386
+ this.addEventListener("keydown", this.keyboardHandler as EventListener);
387
+ }
388
+
389
+ /**
390
+ * Announce message to screen readers
391
+ */
392
+ private announceToScreenReader(message: string): void {
393
+ if (this.ariaLiveRegion) {
394
+ this.ariaLiveRegion.textContent = message;
395
+ // Clear after a brief delay
396
+ setTimeout(() => {
397
+ if (this.ariaLiveRegion) {
398
+ this.ariaLiveRegion.textContent = "";
399
+ }
400
+ }, 1000);
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Add preconnect and dns-prefetch hints for Vimeo domains
406
+ */
407
+ private addResourceHints(): void {
408
+ const hints = [
409
+ { rel: "preconnect", href: "https://player.vimeo.com" },
410
+ { rel: "preconnect", href: "https://i.vimeocdn.com" },
411
+ { rel: "dns-prefetch", href: "https://player.vimeo.com" },
412
+ { rel: "dns-prefetch", href: "https://i.vimeocdn.com" },
413
+ ];
414
+
415
+ hints.forEach(({ rel, href }) => {
416
+ if (!document.querySelector(`link[rel="${rel}"][href="${href}"]`)) {
417
+ const link = document.createElement("link");
418
+ link.rel = rel;
419
+ link.href = href;
420
+ if (rel === "preconnect") {
421
+ link.crossOrigin = "anonymous";
422
+ }
423
+ document.head.appendChild(link);
424
+ }
425
+ });
426
+ }
427
+
428
+ /**
429
+ * Setup Intersection Observer for lazy loading
430
+ */
431
+ private setupIntersectionObserver(): void {
432
+ if (!("IntersectionObserver" in window)) {
433
+ // Fallback: load immediately if IntersectionObserver not supported
434
+ return;
435
+ }
436
+
437
+ const options: IntersectionObserverInit = {
438
+ root: null,
439
+ rootMargin: "50px", // Start loading 50px before entering viewport
440
+ threshold: 0.01,
441
+ };
442
+
443
+ this.intersectionObserver = new IntersectionObserver((entries) => {
444
+ entries.forEach((entry) => {
445
+ if (entry.isIntersecting && !this.hasLoadedVideo) {
446
+ this.hasLoadedVideo = true;
447
+ this.log("VimeoEmbed: Video entering viewport, loading...");
448
+
449
+ // Check if poster was clicked before intersection
450
+ const shouldAutoplay =
451
+ this.getAttribute("data-poster-autoplay") === "true";
452
+
453
+ if (!shouldAutoplay) {
454
+ // Just load the player, don't autoplay
455
+ this.initializePlayer(this.#videoId);
456
+ }
457
+
458
+ // Disconnect observer after loading
459
+ if (this.intersectionObserver) {
460
+ this.intersectionObserver.disconnect();
461
+ }
462
+ }
463
+ });
464
+ }, options);
465
+
466
+ this.intersectionObserver.observe(this);
467
+ }
468
+
469
+ /**
470
+ * Display error message to user with retry option
471
+ */
472
+ private showErrorMessage(message: string): void {
473
+ const errorDiv = document.createElement("div");
474
+ errorDiv.className = "vimeo-error-message";
475
+ errorDiv.setAttribute("role", "alert");
476
+ errorDiv.innerHTML = `
477
+ <div class="error-content">
478
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
479
+ <circle cx="12" cy="12" r="10"></circle>
480
+ <line x1="12" y1="8" x2="12" y2="12"></line>
481
+ <line x1="12" y1="16" x2="12.01" y2="16"></line>
482
+ </svg>
483
+ <p class="error-message">${message}</p>
484
+ <button class="retry-button">Retry</button>
485
+ </div>
486
+ `;
487
+
488
+ const retryButton = errorDiv.querySelector(".retry-button");
489
+ if (retryButton) {
490
+ retryButton.addEventListener("click", () => {
491
+ this.apiLoadRetries = 0;
492
+ errorDiv.remove();
493
+ if (this.#videoId) {
494
+ this.initializePlayer(this.#videoId);
495
+ }
496
+ });
497
+ }
498
+
499
+ // Clear existing content
500
+ this.innerHTML = "";
501
+ this.appendChild(errorDiv);
502
+ }
503
+
504
+ connectedCallback() {
505
+ VimeoEmbed.instanceCount++;
506
+ this.log(
507
+ "VimeoEmbed: connectedCallback called, instance count:",
508
+ VimeoEmbed.instanceCount
509
+ );
510
+
511
+ // Add resource hints on first instance
512
+ if (VimeoEmbed.instanceCount === 1) {
513
+ this.addResourceHints();
514
+ }
515
+
516
+ const urlAttr = this.getAttribute("url");
517
+ const videoIdAttr = this.getAttribute("video-id");
518
+
519
+ if (urlAttr) {
520
+ this.#url = urlAttr;
521
+ this.#videoId = this.extractVideoId(urlAttr);
522
+ } else if (videoIdAttr) {
523
+ this.#videoId = videoIdAttr;
524
+ }
525
+
526
+ this.#autoplay = this.hasAttribute("autoplay");
527
+ this.#controls = this.hasAttribute("controls");
528
+ this.#lazy = this.hasAttribute("lazy");
529
+ this.#muted = this.hasAttribute("muted");
530
+ this.#background = this.hasAttribute("background");
531
+
532
+ this.#poster = this.getAttribute("poster") || "";
533
+ try {
534
+ const playerVarsAttr = this.getAttribute("player-vars");
535
+ this.#playerVars = playerVarsAttr ? JSON.parse(playerVarsAttr) : {};
536
+ } catch (e) {
537
+ this.error("VimeoEmbed: Invalid player-vars JSON on init:", e);
538
+ this.#playerVars = {};
539
+ }
540
+
541
+ if (!this.#videoId) {
542
+ this.error("VimeoEmbed: Missing or invalid video ID");
543
+ this.dispatchCustomEvent("error", {
544
+ message: "Missing or invalid video ID",
545
+ });
546
+ return;
547
+ }
548
+
549
+ this.classList.add("vimeo-embed-wrapper");
550
+
551
+ // Add ARIA role and tabindex for accessibility
552
+ this.setAttribute("role", "region");
553
+ this.setAttribute("aria-label", "Video player");
554
+ if (!this.hasAttribute("tabindex")) {
555
+ this.setAttribute("tabindex", "0");
556
+ }
557
+
558
+ // Create ARIA live region for screen reader announcements
559
+ this.ariaLiveRegion = document.createElement("div");
560
+ this.ariaLiveRegion.setAttribute("aria-live", "polite");
561
+ this.ariaLiveRegion.setAttribute("aria-atomic", "true");
562
+ this.ariaLiveRegion.className = "sr-only";
563
+ this.ariaLiveRegion.style.cssText =
564
+ "position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;";
565
+ this.appendChild(this.ariaLiveRegion);
566
+
567
+ // Setup keyboard event handlers
568
+ this.setupKeyboardHandlers();
569
+
570
+ // Setup fullscreen change listener
571
+ this.setupFullscreenListener();
572
+
573
+ this.#updateBackgroundMode();
574
+
575
+ if (this.#lazy) {
576
+ this.showPoster(this.#videoId);
577
+ // Setup Intersection Observer for better performance
578
+ this.setupIntersectionObserver();
579
+ } else {
580
+ this.initializePlayer(this.#videoId);
581
+ }
582
+ this.initialized = true;
583
+ this.dispatchCustomEvent("connected");
584
+ }
585
+
586
+ disconnectedCallback() {
587
+ VimeoEmbed.instanceCount--;
588
+ this.log(
589
+ "VimeoEmbed: disconnectedCallback called, remaining instances:",
590
+ VimeoEmbed.instanceCount
591
+ );
592
+
593
+ if (this.player) {
594
+ try {
595
+ this.player.destroy();
596
+ this.log("VimeoEmbed: Player destroyed.");
597
+ } catch (error) {
598
+ this.warn("VimeoEmbed: Error destroying player:", error);
599
+ }
600
+ this.player = null;
601
+ }
602
+
603
+ // Remove keyboard event handlers
604
+ if (this.keyboardHandler) {
605
+ this.removeEventListener(
606
+ "keydown",
607
+ this.keyboardHandler as EventListener
608
+ );
609
+ this.keyboardHandler = null;
610
+ }
611
+
612
+ const poster = this.querySelector(".vimeo-poster");
613
+ const buttonOverlay = this.querySelector(".button-overlay");
614
+
615
+ if (poster && this.posterClickHandler) {
616
+ poster.removeEventListener("click", this.posterClickHandler);
617
+ this.posterClickHandler = null;
618
+ }
619
+
620
+ if (buttonOverlay && this.posterClickHandler) {
621
+ buttonOverlay.removeEventListener("click", this.posterClickHandler);
622
+ }
623
+
624
+ this.innerHTML = "";
625
+ this.classList.remove(
626
+ "vimeo-embed-wrapper",
627
+ "vimeo-embed-container",
628
+ "is-playing"
629
+ );
630
+
631
+ if (VimeoEmbed.instanceCount === 0) {
632
+ this.log("VimeoEmbed: Last instance, cleaning up global resources");
633
+
634
+ const script = document.querySelector(
635
+ 'script[src="https://player.vimeo.com/api/player.js"]'
636
+ );
637
+ if (script) {
638
+ script.remove();
639
+ this.log("VimeoEmbed: Vimeo API script removed.");
640
+ }
641
+
642
+ VimeoEmbed.apiLoaded = false;
643
+ VimeoEmbed.apiReady = false;
644
+ }
645
+
646
+ this.iframe = null;
647
+ this.playPauseButton = null;
648
+ this.setCustomControlState = null;
649
+
650
+ this.dispatchCustomEvent("disconnected");
651
+ this.log("VimeoEmbed: Cleanup complete.");
652
+ }
653
+
654
+ private reinitializePlayer() {
655
+ if (!this.initialized || !this.#videoId) {
656
+ return;
657
+ }
658
+
659
+ if (this.player) {
660
+ try {
661
+ this.player.destroy();
662
+ } catch (error) {
663
+ this.warn("VimeoEmbed: Error destroying player:", error);
664
+ }
665
+ }
666
+
667
+ if (this.#lazy) {
668
+ this.showPoster(this.#videoId);
669
+ } else {
670
+ this.initializePlayer(this.#videoId);
671
+ }
672
+ }
673
+
674
+ private async showPoster(videoId: string) {
675
+ let thumbnailUrl = this.#poster;
676
+
677
+ // Fetch Vimeo thumbnail if no custom poster
678
+ if (!this.isValidPosterUrl(this.#poster)) {
679
+ try {
680
+ const response = await fetch(
681
+ `https://vimeo.com/api/v2/video/${videoId}.json`
682
+ );
683
+ const data = await response.json();
684
+ thumbnailUrl = data[0]?.thumbnail_large || "";
685
+ } catch (error) {
686
+ this.warn("VimeoEmbed: Could not fetch Vimeo thumbnail:", error);
687
+ thumbnailUrl = "";
688
+ }
689
+ }
690
+
691
+ this.log("VimeoEmbed: Using poster URL:", thumbnailUrl);
692
+
693
+ this.innerHTML = "";
694
+
695
+ if (this.posterClickHandler) {
696
+ this.posterClickHandler = null;
697
+ }
698
+
699
+ const poster = document.createElement("img");
700
+ poster.src = thumbnailUrl || "";
701
+ poster.alt = "Vimeo Video Thumbnail";
702
+ poster.classList.add("vimeo-poster", "video-poster");
703
+ poster.loading = "lazy";
704
+
705
+ poster.onerror = () => {
706
+ this.warn("VimeoEmbed: Poster failed to load, using white background.");
707
+ poster.style.display = "none";
708
+ this.style.backgroundColor = "#FFFFFF";
709
+ };
710
+
711
+ const buttonOverlay = document.createElement("div");
712
+ buttonOverlay.classList.add("button-overlay");
713
+ buttonOverlay.setAttribute("role", "button");
714
+ buttonOverlay.setAttribute("tabindex", "0");
715
+ buttonOverlay.setAttribute("aria-label", "Play video");
716
+
717
+ const button = document.createElement("div");
718
+ button.classList.add("button");
719
+ button.setAttribute("aria-hidden", "true");
720
+
721
+ buttonOverlay.appendChild(button);
722
+
723
+ const loadVideo: EventListener = () => {
724
+ this.log("VimeoEmbed: Loading video from poster click");
725
+ this.setAttribute("data-poster-autoplay", "true");
726
+ try {
727
+ this.initializePlayer(videoId);
728
+ } catch (error) {
729
+ this.error("VimeoEmbed: Error initializing player from poster:", error);
730
+ this.dispatchCustomEvent("error", { message: "Failed to load video" });
731
+ }
732
+ };
733
+
734
+ this.posterClickHandler = loadVideo;
735
+
736
+ // Add keyboard support
737
+ const keyboardActivate = (e: KeyboardEvent) => {
738
+ if (e.key === "Enter" || e.key === " ") {
739
+ e.preventDefault();
740
+ loadVideo(e);
741
+ }
742
+ };
743
+
744
+ poster.addEventListener("click", loadVideo);
745
+ buttonOverlay.addEventListener("click", loadVideo);
746
+ buttonOverlay.addEventListener(
747
+ "keydown",
748
+ keyboardActivate as EventListener
749
+ );
750
+
751
+ this.appendChild(poster);
752
+ this.appendChild(buttonOverlay);
753
+
754
+ this.log("VimeoEmbed: Poster displayed for lazy loading");
755
+ }
756
+
757
+ private async initializePlayer(videoId: string) {
758
+ this.log("VimeoEmbed: Initializing player for video ID:", videoId);
759
+
760
+ const isBackground = this.#background;
761
+ const autoplay =
762
+ isBackground ||
763
+ this.hasAttribute("autoplay") ||
764
+ this.hasAttribute("data-poster-autoplay") ||
765
+ this.hasAttribute("data-should-autoplay");
766
+ const controls = isBackground ? false : this.hasAttribute("controls");
767
+ const mute = isBackground || autoplay;
768
+
769
+ if (this.hasAttribute("data-poster-autoplay")) {
770
+ this.removeAttribute("data-poster-autoplay");
771
+ }
772
+ if (this.hasAttribute("data-should-autoplay")) {
773
+ this.removeAttribute("data-should-autoplay");
774
+ }
775
+
776
+ this.log(
777
+ "VimeoEmbed: Creating iframe with autoplay:",
778
+ autoplay,
779
+ "controls:",
780
+ controls
781
+ );
782
+
783
+ this.iframe = document.createElement("iframe");
784
+
785
+ // Generate unique ID for Vimeo Player API
786
+ const iframeId = `vimeo-player-${videoId}-${Date.now()}`;
787
+ this.iframe.id = iframeId;
788
+
789
+ const params = new URLSearchParams({
790
+ autoplay: autoplay ? "1" : "0",
791
+ controls: controls ? "1" : "0",
792
+ muted: mute ? "1" : "0",
793
+ playsinline: "1",
794
+ dnt: "1", // Do not track
795
+ ...this.#playerVars,
796
+ });
797
+
798
+ if (this.#muted) {
799
+ params.set("muted", "1");
800
+ }
801
+
802
+ if (autoplay && !this.#muted) {
803
+ this.#muted = true;
804
+ this.log(
805
+ "VimeoEmbed: Autoplay enabled, forcing muted state for compliance"
806
+ );
807
+ }
808
+
809
+ this.iframe.src = `https://player.vimeo.com/video/${videoId}?${params.toString()}`;
810
+ this.iframe.allow =
811
+ "autoplay; fullscreen; picture-in-picture; clipboard-write";
812
+ this.iframe.allowFullscreen = true;
813
+ this.iframe.style.position = "absolute";
814
+ this.iframe.style.border = "none";
815
+
816
+ if (this.#background) {
817
+ this.iframe.style.pointerEvents = "none";
818
+ } else {
819
+ this.iframe.style.top = "0";
820
+ this.iframe.style.left = "0";
821
+ this.iframe.style.width = "100%";
822
+ this.iframe.style.height = "100%";
823
+ }
824
+
825
+ this.log("VimeoEmbed: Iframe created with src:", this.iframe.src);
826
+
827
+ this.replaceChildren(this.iframe);
828
+
829
+ // Store reference to iframe before async operations
830
+ const iframe = this.iframe;
831
+
832
+ if (!controls) {
833
+ this.addCustomControls();
834
+ }
835
+
836
+ try {
837
+ await VimeoEmbed.loadVimeoAPIWithRetry(this.apiLoadRetries);
838
+
839
+ // Check if component was disconnected during API load
840
+ if (!this.isConnected || !iframe.isConnected) {
841
+ this.log(
842
+ "VimeoEmbed: Component disconnected, skipping Player initialization"
843
+ );
844
+ return;
845
+ }
846
+
847
+ // Wait for iframe to be fully in DOM before initializing player
848
+ await new Promise((resolve) => setTimeout(resolve, 100));
849
+
850
+ // @ts-ignore - Vimeo Player will be available after loading the API
851
+ // Use the iframe element directly since it's already in the DOM
852
+ this.player = new Vimeo.Player(iframe);
853
+
854
+ this.player.on("loaded", () => {
855
+ this.log("VimeoEmbed: Player is ready.");
856
+ this.playerReady = true;
857
+
858
+ if (this.#muted) {
859
+ this.player.setVolume(0);
860
+ }
861
+
862
+ if (autoplay && this.setCustomControlState) {
863
+ this.setCustomControlState(true);
864
+ this.#playing = true;
865
+ }
866
+
867
+ this.dispatchCustomEvent("ready");
868
+ });
869
+
870
+ this.player.on("play", () => {
871
+ this.#playing = true;
872
+ if (this.setCustomControlState) {
873
+ this.setCustomControlState(true);
874
+ }
875
+ this.dispatchCustomEvent("play");
876
+ });
877
+
878
+ this.player.on("pause", () => {
879
+ this.#playing = false;
880
+ if (this.setCustomControlState) {
881
+ this.setCustomControlState(false);
882
+ }
883
+ this.dispatchCustomEvent("pause");
884
+ });
885
+
886
+ this.player.on("ended", () => {
887
+ this.#playing = false;
888
+ if (this.setCustomControlState) {
889
+ this.setCustomControlState(false);
890
+ }
891
+ this.dispatchCustomEvent("ended");
892
+ });
893
+
894
+ this.player.on("error", (error: any) => {
895
+ this.error("VimeoEmbed: Player encountered an error:", error);
896
+ this.dispatchCustomEvent("error", {
897
+ message: error.message || "Unknown error occurred",
898
+ name: error.name,
899
+ });
900
+ });
901
+ } catch (error) {
902
+ this.error("VimeoEmbed: Failed to load Vimeo API", error);
903
+ this.showErrorMessage(
904
+ error instanceof Error
905
+ ? error.message
906
+ : "Failed to load Vimeo player. Please refresh the page or check your connection."
907
+ );
908
+ this.dispatchCustomEvent("error", {
909
+ message: error instanceof Error ? error.message : "API load failed",
910
+ code: "API_LOAD_ERROR",
911
+ retryable: true,
912
+ });
913
+ }
914
+ }
915
+
916
+ private static async loadVimeoAPI(): Promise<void> {
917
+ return new Promise((resolve, reject) => {
918
+ if (this.apiReady) {
919
+ resolve();
920
+ return;
921
+ }
922
+
923
+ if (!this.apiLoaded) {
924
+ const script = document.createElement("script");
925
+ script.src = "https://player.vimeo.com/api/player.js";
926
+ script.onload = () => {
927
+ this.apiReady = true;
928
+ this.apiLoaded = true;
929
+ resolve();
930
+ };
931
+ script.onerror = () => {
932
+ reject(
933
+ new Error(
934
+ "Failed to load Vimeo API script. Please check your network connection or disable ad blockers."
935
+ )
936
+ );
937
+ };
938
+ document.head.appendChild(script);
939
+ this.apiLoaded = true;
940
+ } else {
941
+ // Wait for API to be ready
942
+ const checkReady = setInterval(() => {
943
+ // @ts-ignore
944
+ if (typeof Vimeo !== "undefined") {
945
+ this.apiReady = true;
946
+ clearInterval(checkReady);
947
+ resolve();
948
+ }
949
+ }, 100);
950
+
951
+ setTimeout(() => {
952
+ clearInterval(checkReady);
953
+ reject(
954
+ new Error(
955
+ "Vimeo API loading timeout. The API script may be blocked by an ad blocker or network issue."
956
+ )
957
+ );
958
+ }, this.API_LOAD_TIMEOUT);
959
+ }
960
+ });
961
+ }
962
+
963
+ /**
964
+ * Load Vimeo API with retry mechanism
965
+ */
966
+ private static async loadVimeoAPIWithRetry(retries = 0): Promise<void> {
967
+ try {
968
+ await this.loadVimeoAPI();
969
+ } catch (error) {
970
+ if (retries < this.MAX_API_RETRIES) {
971
+ console.warn(
972
+ `VimeoEmbed: API load failed, retrying (${retries + 1}/${
973
+ this.MAX_API_RETRIES
974
+ })...`
975
+ );
976
+ await new Promise((resolve) =>
977
+ setTimeout(resolve, this.API_RETRY_DELAY)
978
+ );
979
+ return this.loadVimeoAPIWithRetry(retries + 1);
980
+ }
981
+ throw error;
982
+ }
983
+ }
984
+
985
+ private addCustomControls() {
986
+ this.classList.add("vimeo-embed-container");
987
+
988
+ const buttonOverlay = document.createElement("div");
989
+ buttonOverlay.classList.add("button-overlay");
990
+
991
+ this.playPauseButton = document.createElement("div");
992
+ this.playPauseButton.classList.add("button");
993
+ let isPlaying = false;
994
+
995
+ const setPlayingState = (playing: boolean) => {
996
+ isPlaying = playing;
997
+ if (playing) {
998
+ this.classList.add("is-playing");
999
+ } else {
1000
+ this.classList.remove("is-playing");
1001
+ }
1002
+ };
1003
+
1004
+ const togglePlayPause = () => {
1005
+ if (isPlaying) {
1006
+ if (this.player) {
1007
+ this.player.pause();
1008
+ }
1009
+ setPlayingState(false);
1010
+ } else {
1011
+ if (this.player) {
1012
+ this.player.play();
1013
+ }
1014
+ setPlayingState(true);
1015
+ }
1016
+ };
1017
+
1018
+ this.setCustomControlState = setPlayingState;
1019
+
1020
+ buttonOverlay.addEventListener("click", togglePlayPause);
1021
+ if (this.playPauseButton) {
1022
+ buttonOverlay.appendChild(this.playPauseButton);
1023
+ }
1024
+ this.appendChild(buttonOverlay);
1025
+ }
1026
+
1027
+ #updateBackgroundMode() {
1028
+ if (this.#background) {
1029
+ this.classList.add("is-background");
1030
+ this.#autoplay = true;
1031
+ this.#muted = true;
1032
+ this.#controls = false;
1033
+ this.#lazy = false;
1034
+ } else {
1035
+ this.classList.remove("is-background");
1036
+ }
1037
+ }
1038
+
1039
+ public async play(): Promise<void> {
1040
+ if (this.#lazy && !this.player && !this.iframe) {
1041
+ this.log(
1042
+ "VimeoEmbed: Lazy video needs to be loaded first. Initializing..."
1043
+ );
1044
+ this.setAttribute("data-should-autoplay", "true");
1045
+ await this.initializePlayer(this.#videoId);
1046
+ await new Promise((resolve) => setTimeout(resolve, 1000));
1047
+ }
1048
+
1049
+ if (!this.playerReady) {
1050
+ this.warn("VimeoEmbed: Player is not ready. Waiting for initialization.");
1051
+ await new Promise((resolve) => {
1052
+ const interval = setInterval(() => {
1053
+ if (this.playerReady) {
1054
+ clearInterval(interval);
1055
+ resolve(null);
1056
+ }
1057
+ }, 100);
1058
+ setTimeout(() => {
1059
+ clearInterval(interval);
1060
+ resolve(null);
1061
+ }, 5000);
1062
+ });
1063
+ }
1064
+
1065
+ this.removeAttribute("data-should-autoplay");
1066
+
1067
+ if (this.player) {
1068
+ this.player.play();
1069
+ this.log("VimeoEmbed: Video playback started via API.");
1070
+ }
1071
+
1072
+ this.#playing = true;
1073
+ if (this.setCustomControlState) {
1074
+ this.setCustomControlState(true);
1075
+ }
1076
+
1077
+ this.dispatchCustomEvent("play");
1078
+ }
1079
+
1080
+ public pause() {
1081
+ if (this.player) {
1082
+ this.player.pause();
1083
+ this.log("VimeoEmbed: Video paused via API.");
1084
+ }
1085
+
1086
+ this.#playing = false;
1087
+ if (this.setCustomControlState) {
1088
+ this.setCustomControlState(false);
1089
+ }
1090
+
1091
+ this.dispatchCustomEvent("pause");
1092
+ }
1093
+
1094
+ public stopVideo() {
1095
+ if (this.player) {
1096
+ this.player.pause();
1097
+ this.player.setCurrentTime(0);
1098
+ this.log("VimeoEmbed: Video stopped via API.");
1099
+ }
1100
+
1101
+ this.#playing = false;
1102
+ if (this.setCustomControlState) {
1103
+ this.setCustomControlState(false);
1104
+ }
1105
+
1106
+ this.dispatchCustomEvent("stop");
1107
+ }
1108
+
1109
+ public mute() {
1110
+ this.muted = true;
1111
+ }
1112
+
1113
+ public unmute() {
1114
+ this.muted = false;
1115
+ }
1116
+
1117
+ public togglePlay() {
1118
+ if (this.#playing) {
1119
+ this.pause();
1120
+ } else {
1121
+ this.play();
1122
+ }
1123
+ }
1124
+
1125
+ public toggleMute() {
1126
+ this.muted = !this.#muted;
1127
+ }
1128
+
1129
+ public static toggleDebug(forceState?: boolean) {
1130
+ VimeoEmbed.DEBUG =
1131
+ forceState !== undefined ? forceState : !VimeoEmbed.DEBUG;
1132
+ console.log(
1133
+ `VimeoEmbed: Debugging is now ${
1134
+ VimeoEmbed.DEBUG ? "ENABLED" : "DISABLED"
1135
+ }.`
1136
+ );
1137
+ }
1138
+
1139
+ public getPlayerState() {
1140
+ let actualMutedState = this.#muted;
1141
+ if (this.player && this.playerReady && !this.updatingMutedState) {
1142
+ try {
1143
+ this.player.getVolume().then((volume: number) => {
1144
+ actualMutedState = volume === 0;
1145
+ if (actualMutedState !== this.#muted && !this.updatingMutedState) {
1146
+ this.#muted = actualMutedState;
1147
+ }
1148
+ });
1149
+ } catch (error) {
1150
+ this.warn("VimeoEmbed: Could not get muted state from player:", error);
1151
+ }
1152
+ }
1153
+
1154
+ return {
1155
+ playing: this.#playing,
1156
+ muted: actualMutedState,
1157
+ videoId: this.#videoId,
1158
+ ready: this.playerReady,
1159
+ initialized: this.initialized,
1160
+ };
1161
+ }
1162
+
1163
+ public isPlaying(): boolean {
1164
+ return this.#playing;
1165
+ }
1166
+
1167
+ public async isMuted(): Promise<boolean> {
1168
+ if (this.player && this.playerReady) {
1169
+ try {
1170
+ const volume = await this.player.getVolume();
1171
+ return volume === 0;
1172
+ } catch (error) {
1173
+ this.warn("VimeoEmbed: Could not get muted state from player:", error);
1174
+ }
1175
+ }
1176
+ return this.#muted;
1177
+ }
1178
+
1179
+ public async getCurrentTime(): Promise<number> {
1180
+ if (this.player && this.playerReady) {
1181
+ try {
1182
+ return await this.player.getCurrentTime();
1183
+ } catch (error) {
1184
+ this.warn("VimeoEmbed: Could not get current time from player:", error);
1185
+ }
1186
+ }
1187
+ return 0;
1188
+ }
1189
+
1190
+ /**
1191
+ * Request fullscreen mode
1192
+ */
1193
+ enterFullscreen(): Promise<void> {
1194
+ const elem = this as any;
1195
+ if (elem.requestFullscreen) {
1196
+ return elem.requestFullscreen();
1197
+ } else if (elem.webkitRequestFullscreen) {
1198
+ return elem.webkitRequestFullscreen();
1199
+ } else if (elem.mozRequestFullScreen) {
1200
+ return elem.mozRequestFullScreen();
1201
+ } else if (elem.msRequestFullscreen) {
1202
+ return elem.msRequestFullscreen();
1203
+ }
1204
+ return Promise.reject(new Error("Fullscreen API not supported"));
1205
+ }
1206
+
1207
+ /**
1208
+ * Exit fullscreen mode
1209
+ */
1210
+ exitFullscreen(): Promise<void> {
1211
+ const doc = document as any;
1212
+ if (doc.exitFullscreen) {
1213
+ return doc.exitFullscreen();
1214
+ } else if (doc.webkitExitFullscreen) {
1215
+ return doc.webkitExitFullscreen();
1216
+ } else if (doc.mozCancelFullScreen) {
1217
+ return doc.mozCancelFullScreen();
1218
+ } else if (doc.msExitFullscreen) {
1219
+ return doc.msExitFullscreen();
1220
+ }
1221
+ return Promise.reject(new Error("Fullscreen API not supported"));
1222
+ }
1223
+
1224
+ /**
1225
+ * Toggle fullscreen mode
1226
+ */
1227
+ async toggleFullscreen(): Promise<void> {
1228
+ if (this.isFullscreen()) {
1229
+ await this.exitFullscreen();
1230
+ } else {
1231
+ await this.enterFullscreen();
1232
+ }
1233
+ }
1234
+
1235
+ /**
1236
+ * Check if currently in fullscreen mode
1237
+ */
1238
+ isFullscreen(): boolean {
1239
+ const doc = document as any;
1240
+ return !!(
1241
+ doc.fullscreenElement ||
1242
+ doc.webkitFullscreenElement ||
1243
+ doc.mozFullScreenElement ||
1244
+ doc.msFullscreenElement
1245
+ );
1246
+ }
1247
+
1248
+ /**
1249
+ * Get available quality levels for the current video
1250
+ * @returns Array of available quality levels
1251
+ */
1252
+ async getAvailableQualities(): Promise<string[]> {
1253
+ if (this.player && this.playerReady) {
1254
+ try {
1255
+ return (await this.player.getQualities()) || [];
1256
+ } catch (error) {
1257
+ this.warn("VimeoEmbed: Could not get available qualities:", error);
1258
+ }
1259
+ }
1260
+ return [];
1261
+ }
1262
+
1263
+ /**
1264
+ * Get the current playback quality
1265
+ * @returns Current quality level
1266
+ */
1267
+ async getCurrentQuality(): Promise<string> {
1268
+ if (this.player && this.playerReady) {
1269
+ try {
1270
+ return (await this.player.getQuality()) || "auto";
1271
+ } catch (error) {
1272
+ this.warn("VimeoEmbed: Could not get current quality:", error);
1273
+ }
1274
+ }
1275
+ return "auto";
1276
+ }
1277
+
1278
+ /**
1279
+ * Set the playback quality
1280
+ * @param quality The desired quality level
1281
+ */
1282
+ async setQuality(quality: string): Promise<void> {
1283
+ if (!this.player || !this.playerReady) {
1284
+ this.warn("VimeoEmbed: Player not ready for quality change");
1285
+ return;
1286
+ }
1287
+
1288
+ try {
1289
+ const oldQuality = await this.getCurrentQuality();
1290
+ await this.player.setQuality(quality);
1291
+
1292
+ // Dispatch quality change event
1293
+ const event = new CustomEvent("qualitychange", {
1294
+ detail: {
1295
+ oldQuality,
1296
+ newQuality: quality,
1297
+ availableQualities: await this.getAvailableQualities(),
1298
+ },
1299
+ bubbles: true,
1300
+ composed: true,
1301
+ });
1302
+ this.dispatchEvent(event);
1303
+ } catch (error) {
1304
+ this.warn("VimeoEmbed: Could not set quality:", error);
1305
+ }
1306
+ }
1307
+
1308
+ public loadVideo(videoIdOrUrl: string) {
1309
+ const extracted = this.extractVideoId(videoIdOrUrl);
1310
+ if (extracted) {
1311
+ this.#url = videoIdOrUrl;
1312
+ this.#videoId = extracted;
1313
+ } else {
1314
+ this.#videoId = videoIdOrUrl;
1315
+ }
1316
+
1317
+ if (this.#lazy && !this.player) {
1318
+ try {
1319
+ this.initializePlayer(this.#videoId);
1320
+ } catch (error) {
1321
+ this.warn("VimeoEmbed: loadVideo failed to initialize player:", error);
1322
+ }
1323
+ return;
1324
+ }
1325
+
1326
+ if (this.initialized) {
1327
+ this.reinitializePlayer();
1328
+ }
1329
+ }
1330
+ }
1331
+
1332
+ customElements.define("vimeo-embed", VimeoEmbed);
1333
+
1334
+ declare global {
1335
+ interface Window {
1336
+ Vimeo?: any;
1337
+ }
1338
+ }
1339
+
1340
+ export {};