From 77cfecaecbf288c437f7ca626c9d1590ee0218f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Nov 2025 11:31:13 +0000 Subject: [PATCH] fix(events): Fix YouTube API initialization timing issue Problem: - Users reported seeing CHECKPOINT 1 but not CHECKPOINT 2 - The iframe was being added to the DOM, but YouTube Player API wasn't initializing - Root cause: "listening" postMessage was sent before iframe fully loaded Solution: - Added iframe onload event listener to wait for full iframe initialization - Send "listening" message immediately when iframe loads - Extended retry delays from [0, 100, 250, 500, 1000]ms to [100, 300, 600, 1200, 2400]ms - Handle race conditions where load event might have already fired - Fallback strategy with longer delays if ref isn't ready Technical Details: - The component now properly waits for iframe's load event before postMessage - YouTube's Player API needs time to initialize even after iframe loads - Retry logic ensures reliability across different network speeds - Maintains backward compatibility - all 51 tests pass Impact: - Users should now consistently see CHECKPOINT 2 (Player Ready) - Events system will work more reliably across different browsers/networks - Better handling of slow network conditions Fixes issue where YouTube Player API wasn't initializing properly. --- dist/index.es.js | 179 ++++++++++++++++++++++--------------------- dist/index.es.js.map | 2 +- dist/index.js | 2 +- dist/index.js.map | 2 +- src/lib/index.tsx | 46 +++++++++-- 5 files changed, 134 insertions(+), 97 deletions(-) diff --git a/dist/index.es.js b/dist/index.es.js index ef54090..32ecc5f 100644 --- a/dist/index.es.js +++ b/dist/index.es.js @@ -11,102 +11,102 @@ */ import { jsxs as b, Fragment as k, jsx as c } from "react/jsx-runtime"; import * as o from "react"; -import { useState as G, useEffect as J } from "react"; -const Q = { +import { useState as Q, useEffect as q } from "react"; +const z = { default: 120, mqdefault: 320, hqdefault: 480, sddefault: 640, maxresdefault: 1280 -}, q = (e, a, u, s = "maxresdefault") => { - const [t, m] = G(""); - return J(() => { - const l = `https://img.youtube.com/${a}/${e}/${s}.${u}`, h = `https://img.youtube.com/${a}/${e}/hqdefault.${u}`, i = Q[s], d = new Image(); +}, K = (e, t, u, s = "maxresdefault") => { + const [a, r] = Q(""); + return q(() => { + const l = `https://img.youtube.com/${t}/${e}/${s}.${u}`, f = `https://img.youtube.com/${t}/${e}/hqdefault.${u}`, i = z[s], d = new Image(); d.onload = () => { - d.width < i ? m(h) : m(l); - }, d.onerror = () => m(h), d.src = l; - }, [e, a, u, s]), t; + d.width < i ? r(f) : r(l); + }, d.onerror = () => r(f), d.src = l; + }, [e, t, u, s]), a; }; -var z = /* @__PURE__ */ ((e) => (e[e.UNSTARTED = -1] = "UNSTARTED", e[e.ENDED = 0] = "ENDED", e[e.PLAYING = 1] = "PLAYING", e[e.PAUSED = 2] = "PAUSED", e[e.BUFFERING = 3] = "BUFFERING", e[e.CUED = 5] = "CUED", e))(z || {}), K = /* @__PURE__ */ ((e) => (e[e.INVALID_PARAM = 2] = "INVALID_PARAM", e[e.HTML5_ERROR = 5] = "HTML5_ERROR", e[e.VIDEO_NOT_FOUND = 100] = "VIDEO_NOT_FOUND", e[e.NOT_EMBEDDABLE = 101] = "NOT_EMBEDDABLE", e[e.NOT_EMBEDDABLE_DISGUISED = 150] = "NOT_EMBEDDABLE_DISGUISED", e))(K || {}); -function X(e, a, u, s, t) { - const m = { +var X = /* @__PURE__ */ ((e) => (e[e.UNSTARTED = -1] = "UNSTARTED", e[e.ENDED = 0] = "ENDED", e[e.PLAYING = 1] = "PLAYING", e[e.PAUSED = 2] = "PAUSED", e[e.BUFFERING = 3] = "BUFFERING", e[e.CUED = 5] = "CUED", e))(X || {}), Z = /* @__PURE__ */ ((e) => (e[e.INVALID_PARAM = 2] = "INVALID_PARAM", e[e.HTML5_ERROR = 5] = "HTML5_ERROR", e[e.VIDEO_NOT_FOUND = 100] = "VIDEO_NOT_FOUND", e[e.NOT_EMBEDDABLE = 101] = "NOT_EMBEDDABLE", e[e.NOT_EMBEDDABLE_DISGUISED = 150] = "NOT_EMBEDDABLE_DISGUISED", e))(Z || {}); +function p(e, t, u, s, a) { + const r = { "@context": "https://schema.org", "@type": "VideoObject", - name: t?.name || a, - thumbnailUrl: [t?.thumbnailUrl || u], - embedUrl: t?.embedUrl || `${s}/embed/${e}`, - contentUrl: t?.contentUrl || `https://www.youtube.com/watch?v=${e}`, - ...t?.description && { description: t.description }, - ...t?.uploadDate && { uploadDate: t.uploadDate }, - ...t?.duration && { duration: t.duration } + name: a?.name || t, + thumbnailUrl: [a?.thumbnailUrl || u], + embedUrl: a?.embedUrl || `${s}/embed/${e}`, + contentUrl: a?.contentUrl || `https://www.youtube.com/watch?v=${e}`, + ...a?.description && { description: a.description }, + ...a?.uploadDate && { uploadDate: a.uploadDate }, + ...a?.duration && { duration: a.duration } }; - return JSON.stringify(m); + return JSON.stringify(r); } -function Z(e, a) { - const [u, s] = o.useState(!1), [t, m] = o.useState(e.alwaysLoadIframe || !1), l = encodeURIComponent(e.id), h = typeof e.playlistCoverId == "string" ? encodeURIComponent(e.playlistCoverId) : null, i = e.title, d = e.poster || "hqdefault", R = e.announce || "Watch", U = e.alwaysLoadIframe ? e.autoplay && e.muted : !0, C = o.useMemo(() => { - const $ = new URLSearchParams({ +function ee(e, t) { + const [u, s] = o.useState(!1), [a, r] = o.useState(e.alwaysLoadIframe || !1), l = encodeURIComponent(e.id), f = typeof e.playlistCoverId == "string" ? encodeURIComponent(e.playlistCoverId) : null, i = e.title, d = e.poster || "hqdefault", U = e.announce || "Watch", T = e.alwaysLoadIframe ? e.autoplay && e.muted : !0, C = o.useMemo(() => { + const v = new URLSearchParams({ ...e.muted ? { mute: "1" } : {}, - ...U ? { autoplay: "1" } : {}, + ...T ? { autoplay: "1" } : {}, ...e.enableJsApi ? { enablejsapi: "1" } : {}, ...e.playlist ? { list: l } : {} }); return e.params && new URLSearchParams( e.params.startsWith("&") ? e.params.slice(1) : e.params ).forEach((g, w) => { - $.append(w, g); - }), $; + v.append(w, g); + }), v; }, [ e.muted, - U, + T, e.enableJsApi, e.playlist, l, e.params - ]), f = o.useMemo( + ]), h = o.useMemo( () => e.cookie ? "https://www.youtube.com" : "https://www.youtube-nocookie.com", [e.cookie] - ), L = o.useMemo( - () => e.playlist ? `${f}/embed/videoseries?${C.toString()}` : `${f}/embed/${l}?${C.toString()}`, - [e.playlist, f, l, C] - ), O = !e.thumbnail && !e.playlist && d === "maxresdefault", I = e.webp ? "webp" : "jpg", D = e.webp ? "vi_webp" : "vi", v = O ? q(e.id, D, I, d) : null, y = o.useMemo( - () => e.thumbnail || v || `https://i.ytimg.com/${D}/${e.playlist ? h : l}/${d}.${I}`, + ), _ = o.useMemo( + () => e.playlist ? `${h}/embed/videoseries?${C.toString()}` : `${h}/embed/${l}?${C.toString()}`, + [e.playlist, h, l, C] + ), M = !e.thumbnail && !e.playlist && d === "maxresdefault", D = e.webp ? "webp" : "jpg", I = e.webp ? "vi_webp" : "vi", A = M ? K(e.id, I, D, d) : null, y = o.useMemo( + () => e.thumbnail || A || `https://i.ytimg.com/${I}/${e.playlist ? f : l}/${d}.${D}`, [ e.thumbnail, - v, - D, + A, + I, e.playlist, - h, + f, l, d, - I + D ] - ), S = e.activatedClass || "lyt-activated", _ = e.adNetwork || !1, M = e.aspectHeight || 9, B = e.aspectWidth || 16, W = e.iframeClass || "", x = e.playerClass || "lty-playbtn", P = e.wrapperClass || "yt-lite", T = o.useCallback( + ), B = e.activatedClass || "lyt-activated", W = e.adNetwork || !1, j = e.aspectHeight || 9, x = e.aspectWidth || 16, P = e.iframeClass || "", F = e.playerClass || "lty-playbtn", Y = e.wrapperClass || "yt-lite", O = o.useCallback( e.onIframeAdded || function() { }, [e.onIframeAdded] - ), F = e.rel ? "prefetch" : "preload", j = e.containerElement || "article", Y = e.noscriptFallback !== !1, H = () => { + ), H = e.rel ? "prefetch" : "preload", V = e.containerElement || "article", G = e.noscriptFallback !== !1, J = () => { u || s(!0); - }, A = () => { - t || m(!0); + }, S = () => { + a || r(!0); }; return o.useEffect(() => { - t && (T(), e.focusOnLoad && typeof a == "object" && a?.current && a.current.focus()); - }, [t, T, e.focusOnLoad, a]), o.useEffect(() => { - if (!t || !e.enableJsApi || !(e.onReady || e.onStateChange || e.onError || e.onPlay || e.onPause || e.onEnd || e.onBuffering || e.onPlaybackRateChange || e.onPlaybackQualityChange)) + a && (O(), e.focusOnLoad && typeof t == "object" && t?.current && t.current.focus()); + }, [a, O, e.focusOnLoad, t]), o.useEffect(() => { + if (!a || !e.enableJsApi || !(e.onReady || e.onStateChange || e.onError || e.onPlay || e.onPause || e.onEnd || e.onBuffering || e.onPlaybackRateChange || e.onPlaybackQualityChange)) return; - let N = !1; - const g = (r) => { - if (r.origin !== "https://www.youtube.com" && r.origin !== "https://www.youtube-nocookie.com") + let $ = !1, g = !1; + const w = (m) => { + if (m.origin !== "https://www.youtube.com" && m.origin !== "https://www.youtube-nocookie.com") return; let n; try { - n = typeof r.data == "string" ? JSON.parse(r.data) : r.data; + n = typeof m.data == "string" ? JSON.parse(m.data) : m.data; } catch { return; } switch (n.event) { case "onReady": - N || (N = !0, e.onReady && e.onReady({ + $ || ($ = !0, e.onReady && e.onReady({ videoId: e.id, title: i })); @@ -126,7 +126,7 @@ function Z(e, a) { e.onPause?.(); break; case 0: - e.onEnd?.(), e.stopOnEnd && typeof a == "object" && a?.current?.contentWindow && a.current.contentWindow.postMessage( + e.onEnd?.(), e.stopOnEnd && typeof t == "object" && t?.current?.contentWindow && t.current.contentWindow.postMessage( '{"event":"command","func":"stopVideo","args":""}', "*" ); @@ -151,20 +151,25 @@ function Z(e, a) { break; } }; - window.addEventListener("message", g); - const w = [], V = () => { - typeof a == "object" && a?.current?.contentWindow && a.current.contentWindow.postMessage( + window.addEventListener("message", w); + const L = [], N = () => { + typeof t == "object" && t?.current?.contentWindow && t.current.contentWindow.postMessage( '{"event":"listening","id":"' + l + '"}', "*" ); + }, R = () => { + if (g) return; + g = !0, N(), [100, 300, 600, 1200, 2400].forEach((n) => { + L.push(setTimeout(N, n)); + }); }; - return [0, 100, 250, 500, 1e3].forEach((r) => { - w.push(setTimeout(V, r)); + return typeof t == "object" && t?.current ? (t.current.addEventListener("load", R), t.current.contentDocument?.readyState === "complete" && R()) : [200, 500, 1e3, 2e3, 3e3].forEach((n) => { + L.push(setTimeout(N, n)); }), () => { - window.removeEventListener("message", g), w.forEach(clearTimeout); + window.removeEventListener("message", w), L.forEach(clearTimeout), typeof t == "object" && t?.current && t.current.removeEventListener("load", R); }; }, [ - t, + a, e.enableJsApi, e.onReady, e.onStateChange, @@ -179,13 +184,13 @@ function Z(e, a) { e.id, l, i, - a + t ]), /* @__PURE__ */ b(k, { children: [ - !e.lazyLoad && /* @__PURE__ */ c("link", { rel: F, href: y, as: "image" }), + !e.lazyLoad && /* @__PURE__ */ c("link", { rel: H, href: y, as: "image" }), /* @__PURE__ */ c(k, { children: u && /* @__PURE__ */ b(k, { children: [ - /* @__PURE__ */ c("link", { rel: "preconnect", href: f }), + /* @__PURE__ */ c("link", { rel: "preconnect", href: h }), /* @__PURE__ */ c("link", { rel: "preconnect", href: "https://www.google.com" }), - _ && /* @__PURE__ */ b(k, { children: [ + W && /* @__PURE__ */ b(k, { children: [ /* @__PURE__ */ c("link", { rel: "preconnect", href: "https://static.doubleclick.net" }), /* @__PURE__ */ c( "link", @@ -201,17 +206,17 @@ function Z(e, a) { { type: "application/ld+json", dangerouslySetInnerHTML: { - __html: X( + __html: p( e.id, i, y, - f, + h, e.seo ) } } ), - Y && !e.playlist && /* @__PURE__ */ c("noscript", { children: /* @__PURE__ */ b( + G && !e.playlist && /* @__PURE__ */ c("noscript", { children: /* @__PURE__ */ b( "a", { href: `https://www.youtube.com/watch?v=${e.id}`, @@ -224,21 +229,21 @@ function Z(e, a) { } ) }), /* @__PURE__ */ b( - j, + V, { - onPointerOver: H, - onClick: A, - className: `${P} ${t ? S : ""}`, + onPointerOver: J, + onClick: S, + className: `${Y} ${a ? B : ""}`, "data-title": i, - role: t ? void 0 : "img", - "aria-label": t ? void 0 : `${i} - YouTube video preview`, + role: a ? void 0 : "img", + "aria-label": a ? void 0 : `${i} - YouTube video preview`, style: { ...!e.lazyLoad && { backgroundImage: `url(${y})` }, - "--aspect-ratio": `${M / B * 100}%`, + "--aspect-ratio": `${j / x * 100}%`, ...e.style || {} }, children: [ - e.lazyLoad && !t && /* @__PURE__ */ c( + e.lazyLoad && !a && /* @__PURE__ */ c( "img", { src: y, @@ -251,25 +256,25 @@ function Z(e, a) { "button", { type: "button", - className: x, - "aria-label": `${R} ${i}`, - "aria-hidden": t || void 0, - tabIndex: t ? -1 : 0, - onClick: A, - children: /* @__PURE__ */ c("span", { className: "lty-visually-hidden", children: R }) + className: F, + "aria-label": `${U} ${i}`, + "aria-hidden": a || void 0, + tabIndex: a ? -1 : 0, + onClick: S, + children: /* @__PURE__ */ c("span", { className: "lty-visually-hidden", children: U }) } ), - t && /* @__PURE__ */ c( + a && /* @__PURE__ */ c( "iframe", { - ref: a, - className: W, + ref: t, + className: P, title: i, width: "560", height: "315", allow: "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture", allowFullScreen: !0, - src: L, + src: _, referrerPolicy: e.referrerPolicy || "strict-origin-when-cross-origin" } ) @@ -278,12 +283,12 @@ function Z(e, a) { ) ] }); } -const ae = o.forwardRef( - Z +const ne = o.forwardRef( + ee ); export { - K as PlayerError, - z as PlayerState, - ae as default + Z as PlayerError, + X as PlayerState, + ne as default }; //# sourceMappingURL=index.es.js.map diff --git a/dist/index.es.js.map b/dist/index.es.js.map index d2785a4..abda671 100644 --- a/dist/index.es.js.map +++ b/dist/index.es.js.map @@ -1 +1 @@ -{"version":3,"file":"index.es.js","sources":["../src/lib/useYoutubeThumbnail.tsx","../src/lib/index.tsx"],"sourcesContent":["import { useState, useEffect } from \"react\";\n\nexport type imgResolution =\n | \"default\"\n | \"mqdefault\"\n | \"hqdefault\"\n | \"sddefault\"\n | \"maxresdefault\";\n\nconst expectedWidths: Record = {\n default: 120,\n mqdefault: 320,\n hqdefault: 480,\n sddefault: 640,\n maxresdefault: 1280,\n};\n\nexport const useYoutubeThumbnail = (\n videoId: string,\n vi: string,\n format: string,\n imageRes: imgResolution = \"maxresdefault\"\n) => {\n const [url, setUrl] = useState(\"\");\n\n useEffect(() => {\n const testUrl = `https://img.youtube.com/${vi}/${videoId}/${imageRes}.${format}`;\n const fallbackUrl = `https://img.youtube.com/${vi}/${videoId}/hqdefault.${format}`;\n\n const expectedWidth = expectedWidths[imageRes];\n\n const img = new Image();\n img.onload = () => {\n if (img.width < expectedWidth) {\n setUrl(fallbackUrl);\n } else {\n setUrl(testUrl);\n }\n };\n img.onerror = () => setUrl(fallbackUrl);\n img.src = testUrl;\n }, [videoId, vi, format, imageRes]);\n\n return url;\n};\n\nexport default useYoutubeThumbnail;\n","import * as React from \"react\";\nimport useYoutubeThumbnail from \"./useYoutubeThumbnail\";\nimport { imgResolution } from \"./useYoutubeThumbnail\";\n\n// Re-export types for public API\nexport type { imgResolution };\n\n/**\n * YouTube Player State constants\n * @see https://developers.google.com/youtube/iframe_api_reference#onStateChange\n */\nexport enum PlayerState {\n UNSTARTED = -1,\n ENDED = 0,\n PLAYING = 1,\n PAUSED = 2,\n BUFFERING = 3,\n CUED = 5,\n}\n\n/**\n * YouTube Player Error codes\n * @see https://developers.google.com/youtube/iframe_api_reference#onError\n */\nexport enum PlayerError {\n INVALID_PARAM = 2,\n HTML5_ERROR = 5,\n VIDEO_NOT_FOUND = 100,\n NOT_EMBEDDABLE = 101,\n NOT_EMBEDDABLE_DISGUISED = 150,\n}\n\n/**\n * YouTube Player Event data structure\n * Represents the data received from YouTube's postMessage API\n */\nexport interface YouTubeEvent {\n info?: {\n playerState?: PlayerState;\n currentTime?: number;\n duration?: number;\n videoData?: {\n video_id: string;\n title: string;\n };\n playbackRate?: number;\n playbackQuality?: string;\n };\n}\n\n/**\n * Event handler for player state changes\n */\nexport interface PlayerStateChangeEvent {\n state: PlayerState;\n currentTime?: number;\n duration?: number;\n}\n\n/**\n * Event handler for when player is ready\n */\nexport interface PlayerReadyEvent {\n videoId: string;\n title: string;\n}\n\n/**\n * SEO metadata for YouTube video following schema.org VideoObject structure.\n * See: https://developers.google.com/search/docs/appearance/structured-data/video\n *\n * All fields are optional but providing them improves search engine discoverability\n * and enables rich results (video carousels, thumbnails in search results).\n *\n * Use the provided `scripts/fetch-youtube-metadata.sh` helper to easily retrieve\n * this data from YouTube's API.\n */\nexport interface VideoSEO {\n /**\n * The title of the video. If not provided, falls back to the component's `title` prop.\n * @example \"What's new in Material Design for the web\"\n */\n name?: string;\n\n /**\n * A description of the video content.\n * Recommended: 50-160 characters for optimal search result display.\n * @example \"Learn about the latest Material Design updates presented at Chrome Dev Summit 2019\"\n */\n description?: string;\n\n /**\n * ISO 8601 date when the video was uploaded to YouTube.\n * @example \"2019-11-11T08:00:00Z\" or \"2019-11-11\"\n */\n uploadDate?: string;\n\n /**\n * ISO 8601 duration format. Required for video rich results.\n * Format: PT#H#M#S where # is the number of hours, minutes, seconds\n * @example \"PT1M33S\" (1 minute 33 seconds)\n * @example \"PT15M\" (15 minutes)\n * @example \"PT1H30M\" (1 hour 30 minutes)\n */\n duration?: string;\n\n /**\n * Custom thumbnail URL. If not provided, auto-generated from video ID.\n * Recommended: At least 1200px wide for best quality in search results.\n * @example \"https://i.ytimg.com/vi/VIDEO_ID/maxresdefault.jpg\"\n */\n thumbnailUrl?: string;\n\n /**\n * Direct URL to watch the video. Auto-generated if not provided.\n * @example \"https://www.youtube.com/watch?v=L2vS_050c-M\"\n */\n contentUrl?: string;\n\n /**\n * The embed URL. Auto-generated from video ID if not provided.\n * @example \"https://www.youtube.com/embed/L2vS_050c-M\"\n */\n embedUrl?: string;\n}\n\nexport interface LiteYouTubeProps {\n announce?: string;\n id: string;\n title: string;\n activatedClass?: string;\n adNetwork?: boolean;\n aspectHeight?: number;\n aspectWidth?: number;\n iframeClass?: string;\n /** @deprecated Use cookie prop instead */\n noCookie?: boolean;\n cookie?: boolean;\n enableJsApi?: boolean;\n alwaysLoadIframe?: boolean;\n params?: string;\n playerClass?: string;\n playlist?: boolean;\n playlistCoverId?: string;\n poster?: imgResolution;\n webp?: boolean;\n wrapperClass?: string;\n onIframeAdded?: () => void;\n muted?: boolean;\n autoplay?: boolean;\n thumbnail?: string;\n rel?: string;\n containerElement?: keyof React.JSX.IntrinsicElements;\n style?: React.CSSProperties;\n focusOnLoad?: boolean;\n referrerPolicy?: React.HTMLAttributeReferrerPolicy;\n /**\n * Enable lazy loading for thumbnail image.\n * Uses native browser lazy loading to defer offscreen images.\n * Improves Lighthouse scores and reduces bandwidth for below-fold videos.\n * @default false\n */\n lazyLoad?: boolean;\n /**\n * Stop video and return to thumbnail when playback ends.\n * Prevents YouTube from showing related videos. Requires enableJsApi.\n * @default false\n */\n stopOnEnd?: boolean;\n /**\n * SEO metadata for search engines. Enables rich results and better discoverability.\n * Provides structured data following schema.org VideoObject specification.\n * @see https://developers.google.com/search/docs/appearance/structured-data/video\n */\n seo?: VideoSEO;\n /**\n * Include noscript fallback link for accessibility and search crawlers.\n * When true, adds a direct YouTube link inside