From 89205eb8e15754c42be21ad330c1bc15b1c1bb1e Mon Sep 17 00:00:00 2001 From: Ibrahim Cesar Nogueira Bevilacqua Date: Sat, 15 Nov 2025 12:51:55 -0300 Subject: [PATCH] feat: implement playback rate and quality change events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes onPlaybackRateChange and onPlaybackQualityChange events by handling additional properties in YouTube's infoDelivery postMessage events. ## What Changed ### Core Implementation (src/lib/index.tsx) - Added handlers for `playbackRate` and `playbackQuality` in infoDelivery event - YouTube sends these properties via the same infoDelivery events as state changes - Events now fire when user changes speed or quality via ⚙️ settings button ### Documentation (README.md) - Updated Event Compatibility Status table - both events now ✅ Verified Working - Added testing instructions for ⚙️ settings button usage - Enhanced Advanced Events section with practical examples - Added new "Event Status Tracker Demo" section - Updated Quick Start example to showcase new events - Updated Core Features section to highlight all events verified ### Demo Updates (demo/pages/index.js) - Event Status Tracker now properly marks playback rate/quality as fired - Added testing instructions in UI - Enhanced event handler logging ## Technical Details YouTube's iframe postMessage API sends multiple event types via `infoDelivery`: - `info.playerState` - player state changes (already handled) - `info.playbackRate` - playback speed changes (NEW) - `info.playbackQuality` - video quality changes (NEW) The infoDelivery handler now checks for all three properties and calls the appropriate callback handlers. ## Testing 1. Play video in Events demo 2. Click ⚙️ settings button in YouTube player 3. Change "Playback speed" → onPlaybackRateChange fires 4. Change "Quality" → onPlaybackQualityChange fires 5. Event Status Tracker shows ✅ for both events ## Event Status All core events now fully tested and verified: ✅ onIframeAdded ✅ onReady ✅ onStateChange ✅ onPlay ✅ onPause ✅ onEnd ✅ onBuffering ✅ onPlaybackRateChange (NEW - now working) ✅ onPlaybackQualityChange (NEW - now working) ⚠️ onError (untested - requires invalid video) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 131 +++++++++++++++++++++-- demo/pages/index.js | 248 +++++++++++++++++++++++++++++++------------ dist/index.es.js | 167 +++++++++++++++++------------ dist/index.es.js.map | 2 +- dist/index.js | 4 +- dist/index.js.map | 2 +- src/lib/index.tsx | 61 ++++++++++- 7 files changed, 467 insertions(+), 148 deletions(-) diff --git a/README.md b/README.md index 95f43ef..cab1c49 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Help search engines discover your videos with structured data: ### 🎬 Player Events (New in v3) -React to player state changes, playback controls, and errors: +React to player state changes, playback controls, quality, and errors. **All core events are fully tested and verified working!** ```tsx import LiteYouTubeEmbed, { PlayerState, PlayerError } from 'react-lite-youtube-embed'; @@ -134,15 +134,16 @@ import LiteYouTubeEmbed, { PlayerState, PlayerError } from 'react-lite-youtube-e title="Video Title" enableJsApi - // Simple handlers + // Simple handlers (✅ All verified) onPlay={() => console.log('Started')} onPause={() => console.log('Paused')} onEnd={() => console.log('Finished')} - // Advanced handlers + // Advanced handlers (✅ All verified) onStateChange={(e) => console.log('State:', e.state)} - onError={(code) => console.error('Error:', code)} onPlaybackRateChange={(rate) => console.log('Speed:', rate)} + onPlaybackQualityChange={(quality) => console.log('Quality:', quality)} + onError={(code) => console.error('Error:', code)} /> ``` @@ -464,7 +465,28 @@ This generates: ### Fetching Video Metadata -Use the included helper script to quickly fetch video metadata: +There are several ways to get complete video metadata for SEO: + +#### Method 1: Manual (Quick & Privacy-Friendly) + +The fastest way is to visit the YouTube video page and get the info directly: + +1. **Open the video:** Visit `https://www.youtube.com/watch?v=VIDEO_ID` +2. **Get the duration:** Look at the video player (e.g., "4:23") +3. **Convert duration to ISO 8601:** + - 4:23 → `PT4M23S` (4 minutes 23 seconds) + - 1:30:45 → `PT1H30M45S` (1 hour 30 minutes 45 seconds) + - 15:00 → `PT15M` (15 minutes) +4. **Get upload date:** Shown below video title (e.g., "Dec 5, 2018") +5. **Convert date to ISO 8601 UTC format:** + - Format: `YYYY-MM-DDTHH:MM:SSZ` (the `Z` indicates UTC timezone) + - Dec 5, 2018 → `2018-12-05T08:00:00Z` + - Jun 5, 2025 → `2025-06-05T08:00:00Z` + - **Note:** The specific time (08:00:00) is not critical for SEO - the date is what matters. You can use `00:00:00Z` or any time if you don't know the exact upload time. + +#### Method 2: Helper Script (Basic Metadata) + +Use the included script to fetch title and thumbnail: ```bash # Make the script executable (first time only) @@ -477,8 +499,26 @@ chmod +x scripts/fetch-youtube-metadata.sh ./scripts/fetch-youtube-metadata.sh dQw4w9WgXcQ --format react ``` +**Note:** This script uses YouTube's oEmbed API which only provides basic info (title, author, thumbnail). You'll need to add `uploadDate` and `duration` manually. + **Requirements:** `curl` and `jq` must be installed. +#### Method 3: YouTube Data API v3 (Complete Metadata) + +For complete automation, use YouTube's official API: + +1. **Get a free API key:** https://console.cloud.google.com/apis/credentials +2. **Make API request:** + ```bash + curl "https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=VIDEO_ID&key=YOUR_API_KEY" + ``` +3. **Extract values from response:** + - `snippet.publishedAt` → uploadDate + - `contentDetails.duration` → duration (already in ISO 8601 format!) + - `snippet.description` → description + +**API Limits:** Free tier provides 10,000 quota units/day (sufficient for most use cases) + ### SEO Prop Reference ```typescript @@ -512,19 +552,58 @@ Test your structured data: ## 🎬 Player Events (New in v3.0) -Get real-time notifications when the YouTube player changes state, encounters errors, or when users interact with playback controls. All event handlers require `enableJsApi={true}` to work. +Get real-time notifications when the YouTube player changes state, encounters errors, or when users interact with playback controls. + +### ⚠️ Requirements + +**CRITICAL:** Events require **both** of these: +1. `enableJsApi={true}` - Enables YouTube's JavaScript API +2. `ref={yourRef}` - A React ref **MUST** be passed to the component (used to communicate with YouTube's iframe) + +Without a ref, events will **NOT** work! + +### ⚠️ Important Notice About YouTube's API + +**This event system relies on YouTube's internal postMessage API**, which is not officially documented by Google and may change at any time without prior notice. While we strive to keep the implementation up-to-date, YouTube could modify their iframe communication protocol in future updates, potentially breaking event functionality. + +**Recommendations:** +- Test events thoroughly in your production environment +- Have fallback behavior if events stop working +- Monitor for breaking changes when updating YouTube embed URLs +- Consider this when building critical features that depend on events + +### Event Compatibility Status + +| Event | Status | Notes | +|-------|--------|-------| +| `onIframeAdded` | ✅ **Verified Working** | Fires when iframe is added to DOM | +| `onReady` | ✅ **Verified Working** | Fires when YouTube player initializes | +| `onStateChange` | ✅ **Verified Working** | Fires on all state changes | +| `onPlay` | ✅ **Verified Working** | Convenience wrapper for PLAYING state | +| `onPause` | ✅ **Verified Working** | Convenience wrapper for PAUSED state | +| `onEnd` | ✅ **Verified Working** | Convenience wrapper for ENDED state | +| `onBuffering` | ✅ **Verified Working** | Convenience wrapper for BUFFERING state | +| `onPlaybackRateChange` | ✅ **Verified Working** | Fires when speed changes (use ⚙️ settings) | +| `onPlaybackQualityChange` | ✅ **Verified Working** | Fires when quality changes (use ⚙️ settings) | +| `onError` | ⚠️ **Untested** | Should work but not confirmed with invalid video | + +**Technical Note:** YouTube sends state changes, playback rate, and quality changes via `infoDelivery` postMessage events. This library handles this automatically. ### Quick Start ```tsx +import { useRef } from 'react'; import LiteYouTubeEmbed, { PlayerState, PlayerError } from 'react-lite-youtube-embed'; function App() { + const ytRef = useRef(null); // ⚠️ REQUIRED for events to work! + return ( console.log('Video started playing')} @@ -537,6 +616,10 @@ function App() { console.log('Current time:', event.currentTime); }} + // Advanced playback handlers + onPlaybackRateChange={(rate) => console.log('Speed:', rate)} + onPlaybackQualityChange={(quality) => console.log('Quality:', quality)} + // Error handling onError={(errorCode) => { if (errorCode === PlayerError.VIDEO_NOT_FOUND) { @@ -635,24 +718,54 @@ Simple wrappers for common use cases: **`onPlaybackRateChange(playbackRate: number)`** -Fires when playback speed changes. Common values: `0.25`, `0.5`, `1`, `1.5`, `2`. +Fires when playback speed changes. To test this event, click the ⚙️ settings button in the YouTube player and change the playback speed. + +Common values: `0.25`, `0.5`, `1`, `1.5`, `2`. ```tsx onPlaybackRateChange={(rate) => { console.log(`Playback speed: ${rate}x`); + // Example: Save user's preferred playback speed + localStorage.setItem('preferredSpeed', rate.toString()); }} ``` **`onPlaybackQualityChange(quality: string)`** -Fires when video quality changes. Values: `"small"` (240p), `"medium"` (360p), `"large"` (480p), `"hd720"`, `"hd1080"`, etc. +Fires when video quality changes (either automatically or manually). To test this event manually, click the ⚙️ settings button in the YouTube player and change the video quality. + +Common values: `"small"` (240p), `"medium"` (360p), `"large"` (480p), `"hd720"`, `"hd1080"`, `"hd1440"`, `"hd2160"` (4K). ```tsx onPlaybackQualityChange={(quality) => { console.log(`Quality changed to: ${quality}`); + // Example: Track quality changes for analytics + analytics.track('video_quality_change', { + quality, + timestamp: Date.now() + }); }} ``` +### Event Status Tracker Demo + +The [live demo](https://ibrahimcesar.github.io/react-lite-youtube-embed) includes an **Event Status Tracker** that visually shows which events have fired during your interaction with the video. Each event displays: + +- ⏸️ **Gray background** - Event has not fired yet +- ✅ **Green background** - Event has fired at least once + +This interactive tracker helps you: +- Understand when different events fire +- Test your event handlers +- Learn about YouTube's event system +- Verify events are working correctly + +**Try it yourself:** +1. Visit the [live demo](https://ibrahimcesar.github.io/react-lite-youtube-embed) +2. Scroll to the Events section +3. Play the video and watch events light up +4. Change playback speed and quality via the ⚙️ settings button + ### Real-World Examples #### Analytics Tracking diff --git a/demo/pages/index.js b/demo/pages/index.js index a1b2378..939d331 100644 --- a/demo/pages/index.js +++ b/demo/pages/index.js @@ -129,16 +129,16 @@ export default function Home() { no ad network preconnect, and hqdefault thumbnail quality.

View Code
                 
 {``}
                 
               
@@ -153,8 +153,8 @@ export default function Home() { Note: maxresdefault isn't available for all videos. Falls back to lower quality if unavailable.

@@ -162,8 +162,8 @@ export default function Home() {
                 
 {``}
                 
@@ -180,8 +180,8 @@ export default function Home() {
               Supported by all modern browsers (97%+ coverage).
             

@@ -190,8 +190,8 @@ export default function Home() {
                 
 {``}
@@ -215,16 +215,16 @@ export default function Home() {
               ensures proper spacing is reserved.
             

View Code
                 
 {``}
                 
@@ -240,21 +240,21 @@ export default function Home() {
               a standard cover image, use playlistCoverId to specify a video ID for the thumbnail.
             

View Code
                 
 {``}
                 
@@ -273,19 +273,19 @@ export default function Home() {
               You can pass any valid YouTube URL parameters this way.
             

View Code
                 
 {``}
                 
@@ -302,11 +302,11 @@ export default function Home() {
               Includes automatic noscript fallback for crawlers.
             

{``} @@ -335,11 +335,11 @@ export default function Home() {

Custom Aspect Ratio

Change the aspect ratio from the default 16:9 using aspectWidth and aspectHeight. - Useful for older videos in 4:3 format or custom video dimensions. + Useful for older videos in 4:3 format or custom video dimensions. This video has the 4:3 aspect ratio.

@@ -348,8 +348,8 @@ export default function Home() {
                 
 {``}
@@ -373,8 +373,8 @@ export default function Home() {
               instead of the default "Watch".
             

@@ -383,8 +383,8 @@ export default function Home() {
                 
 {``}
@@ -397,13 +397,50 @@ export default function Home() {
       
 
       
     
   )
@@ -449,8 +486,8 @@ function PlayerControlExample() {
         {isPlaying ? '⏸ Pause' : '▶ Play'}
       
       
        {
     const timestamp = new Date().toLocaleTimeString();
@@ -536,6 +588,9 @@ function EventsExample() {
     const timestamp = new Date().toLocaleTimeString();
     console.log(`✅ Event fired: ${eventName}`, data || '(no data)');
 
+    // Mark this event as fired
+    setFiredEvents(prev => ({ ...prev, [eventName]: true }));
+
     setEvents(prev => [{
       name: eventName,
       data: data,
@@ -663,7 +718,13 @@ function EventsExample() {
     

Interactive Events Demo 🎉 NEW in v3.0+

- Events are first-class citizens in v3.0+! All event handlers require enableJsApi={'{'}true{'}'}. + Events are first-class citizens in v3.0+! All event handlers require: +

+
    +
  1. enableJsApi={'{'}true{'}'} - Enables YouTube's JavaScript API
  2. +
  3. ref={'{'}yourRef{'}'} - A React ref is REQUIRED for events to work (the component uses it to communicate with YouTube's iframe)
  4. +
+

Play the video below and watch the live event log to see all available events in action. The event log below only captures events from this specific video embed.

@@ -796,7 +857,7 @@ function EventsExample() {
Video Info
ID: {playerInfo.videoId} - {playerInfo.duration && <>
Duration: {playerInfo.duration}s} + {playerInfo.title && <>
Title: {playerInfo.title}}
)} @@ -804,8 +865,9 @@ function EventsExample() { {/* Video Player */} + {/* Event Status Tracker */} +
+

📋 Event Status Tracker

+

+ Interact with the video to fire different events. Green checkmarks ✅ indicate events that have fired at least once. +

+
+ {[ + { name: 'onIframeAdded', label: 'Iframe Added', description: 'Fires when iframe is added to DOM' }, + { name: 'onReady', label: 'Player Ready', description: 'Player initialized successfully' }, + { name: 'onStateChange', label: 'State Change', description: 'Player state changed' }, + { name: 'onPlay', label: 'Play', description: 'Video started playing' }, + { name: 'onPause', label: 'Pause', description: 'Video was paused' }, + { name: 'onEnd', label: 'End', description: 'Video finished playing' }, + { name: 'onBuffering', label: 'Buffering', description: 'Video is buffering' }, + { name: 'onError', label: 'Error', description: 'An error occurred' }, + { name: 'onPlaybackRateChange', label: 'Playback Rate', description: 'Speed changed (use ⚙️ settings)' }, + { name: 'onPlaybackQualityChange', label: 'Quality Change', description: 'Quality changed (use ⚙️ settings)' } + ].map(event => ( +
+
+ + {firedEvents[event.name] ? '✅' : '⏸️'} + + + {event.label} + +
+
+ {event.description} +
+
+ ))} +
+
+ {/* Event Log */}
View Code - All Event Handlers (with Console Debugging)
           
-{`import { useState, useCallback } from 'react';
+{`import { useState, useCallback, useRef } from 'react';
 import LiteYouTubeEmbed from 'react-lite-youtube-embed';
 
 function EventsExample() {
+  const ytRef = useRef(null);  // ⚠️ CRITICAL: ref is REQUIRED for events!
   const [events, setEvents] = useState([]);
   const [currentState, setCurrentState] = useState('Not Started');
 
@@ -913,9 +1029,10 @@ function EventsExample() {
       {/* Current State: {currentState} */}
 
        {
@@ -998,6 +1115,7 @@ function EventsExample() {
         

Problem: Events are not firing at all

    +
  • 🚨 MOST COMMON: Missing ref: You MUST pass a ref to the component! Without it, the component cannot communicate with YouTube. Example: ref={'{'}useRef(null){'}'}
  • Forgot enableJsApi: Ensure enableJsApi={'{'}true{'}'} is set on the component
  • YouTube blocked: Check if ad blockers, firewalls, or network policies block YouTube
  • CORS/iframe restrictions: Some browsers block third-party iframes in certain contexts
  • diff --git a/dist/index.es.js b/dist/index.es.js index 32ecc5f..2fe1338 100644 --- a/dist/index.es.js +++ b/dist/index.es.js @@ -1,5 +1,5 @@ /** -* @ibrahimcesar/react-lite-youtube-embed v3.1.0 +* @ibrahimcesar/react-lite-youtube-embed v3.2.0 * git+https://github.com/ibrahimcesar/react-lite-youtube-embed.git * * Copyright (c) Ibrahim Cesar and project contributors. @@ -9,9 +9,9 @@ * * Author site: https://ibrahimcesar.cloud */ -import { jsxs as b, Fragment as k, jsx as c } from "react/jsx-runtime"; -import * as o from "react"; -import { useState as Q, useEffect as q } from "react"; +import { jsxs as y, Fragment as E, jsx as c } from "react/jsx-runtime"; +import * as l from "react"; +import { useState as G, useEffect as q } from "react"; const z = { default: 120, mqdefault: 320, @@ -19,12 +19,12 @@ const z = { sddefault: 640, maxresdefault: 1280 }, K = (e, t, u, s = "maxresdefault") => { - const [a, r] = Q(""); + const [a, r] = G(""); 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(); + const o = `https://img.youtube.com/${t}/${e}/${s}.${u}`, h = `https://img.youtube.com/${t}/${e}/hqdefault.${u}`, i = z[s], d = new Image(); d.onload = () => { - d.width < i ? r(f) : r(l); - }, d.onerror = () => r(f), d.src = l; + d.width < i ? r(h) : r(o); + }, d.onerror = () => r(h), d.src = o; }, [e, t, u, s]), a; }; 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 || {}); @@ -43,59 +43,60 @@ function p(e, t, u, s, a) { return JSON.stringify(r); } 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 [u, s] = l.useState(!1), [a, r] = l.useState(e.alwaysLoadIframe || !1), o = encodeURIComponent(e.id), h = typeof e.playlistCoverId == "string" ? encodeURIComponent(e.playlistCoverId) : null, i = e.title, d = e.poster || "hqdefault", N = e.announce || "Watch", U = e.alwaysLoadIframe ? e.autoplay && e.muted : !0, C = l.useMemo(() => { const v = new URLSearchParams({ ...e.muted ? { mute: "1" } : {}, - ...T ? { autoplay: "1" } : {}, + ...U ? { autoplay: "1" } : {}, ...e.enableJsApi ? { enablejsapi: "1" } : {}, - ...e.playlist ? { list: l } : {} + ...e.enableJsApi && typeof window < "u" ? { origin: window.location.origin } : {}, + ...e.playlist ? { list: o } : {} }); return e.params && new URLSearchParams( e.params.startsWith("&") ? e.params.slice(1) : e.params - ).forEach((g, w) => { - v.append(w, g); + ).forEach((w, k) => { + v.append(k, w); }), v; }, [ e.muted, - T, + U, e.enableJsApi, e.playlist, - l, + o, e.params - ]), h = o.useMemo( + ]), b = l.useMemo( () => e.cookie ? "https://www.youtube.com" : "https://www.youtube-nocookie.com", [e.cookie] - ), _ = 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}`, + ), M = l.useMemo( + () => e.playlist ? `${b}/embed/videoseries?${C.toString()}` : `${b}/embed/${o}?${C.toString()}`, + [e.playlist, b, o, C] + ), _ = !e.thumbnail && !e.playlist && d === "maxresdefault", D = e.webp ? "webp" : "jpg", I = e.webp ? "vi_webp" : "vi", A = _ ? K(e.id, I, D, d) : null, g = l.useMemo( + () => e.thumbnail || A || `https://i.ytimg.com/${I}/${e.playlist ? h : o}/${d}.${D}`, [ e.thumbnail, A, I, e.playlist, - f, - l, + h, + o, d, D ] - ), 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( + ), P = e.activatedClass || "lyt-activated", W = e.adNetwork || !1, B = e.aspectHeight || 9, j = e.aspectWidth || 16, x = e.iframeClass || "", F = e.playerClass || "lty-playbtn", Q = e.wrapperClass || "yt-lite", S = l.useCallback( e.onIframeAdded || function() { }, [e.onIframeAdded] - ), H = e.rel ? "prefetch" : "preload", V = e.containerElement || "article", G = e.noscriptFallback !== !1, J = () => { + ), V = e.rel ? "prefetch" : "preload", Y = e.containerElement || "article", H = e.noscriptFallback !== !1, J = () => { u || s(!0); - }, S = () => { + }, O = () => { a || r(!0); }; - return o.useEffect(() => { - a && (O(), e.focusOnLoad && typeof t == "object" && t?.current && t.current.focus()); - }, [a, O, e.focusOnLoad, t]), o.useEffect(() => { + return l.useEffect(() => { + a && (S(), e.focusOnLoad && typeof t == "object" && t?.current && t.current.focus()); + }, [a, S, e.focusOnLoad, t]), l.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 $ = !1, g = !1; - const w = (m) => { + let R = !1, w = !1; + const k = (m) => { if (m.origin !== "https://www.youtube.com" && m.origin !== "https://www.youtube-nocookie.com") return; let n; @@ -106,19 +107,46 @@ function ee(e, t) { } switch (n.event) { case "onReady": - $ || ($ = !0, e.onReady && e.onReady({ + R || (R = !0, e.onReady && e.onReady({ videoId: e.id, title: i })); break; + case "infoDelivery": + if (n.info?.playerState !== void 0) { + const f = n.info.playerState; + switch (e.onStateChange && e.onStateChange({ + state: f, + currentTime: n.info.currentTime, + duration: n.info.duration + }), f) { + case 1: + e.onPlay?.(); + break; + case 2: + e.onPause?.(); + break; + case 0: + e.onEnd?.(), e.stopOnEnd && typeof t == "object" && t?.current?.contentWindow && t.current.contentWindow.postMessage( + '{"event":"command","func":"stopVideo","args":""}', + "*" + ); + break; + case 3: + e.onBuffering?.(); + break; + } + } + n.info?.playbackRate !== void 0 && e.onPlaybackRateChange?.(n.info.playbackRate), n.info?.playbackQuality !== void 0 && e.onPlaybackQualityChange?.(n.info.playbackQuality); + break; case "onStateChange": if (n.info?.playerState !== void 0) { - const E = n.info.playerState; + const f = n.info.playerState; switch (e.onStateChange && e.onStateChange({ - state: E, + state: f, currentTime: n.info.currentTime, duration: n.info.duration - }), E) { + }), f) { case 1: e.onPlay?.(); break; @@ -139,8 +167,8 @@ function ee(e, t) { break; case "onError": if (n.info && "errorCode" in n.info) { - const E = n.info.errorCode; - e.onError && e.onError(E); + const f = n.info.errorCode; + e.onError && e.onError(f); } break; case "onPlaybackRateChange": @@ -151,22 +179,23 @@ function ee(e, t) { break; } }; - window.addEventListener("message", w); - const L = [], N = () => { + window.addEventListener("message", k); + const T = [], $ = () => { typeof t == "object" && t?.current?.contentWindow && t.current.contentWindow.postMessage( - '{"event":"listening","id":"' + l + '"}', + '{"event":"listening","id":"' + o + '"}', "*" ); - }, R = () => { - if (g) return; - g = !0, N(), [100, 300, 600, 1200, 2400].forEach((n) => { - L.push(setTimeout(N, n)); + }, L = () => { + if (w) + return; + w = !0, $(), [100, 300, 600, 1200, 2400].forEach((n) => { + T.push(setTimeout($, n)); }); }; - 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)); + return typeof t == "object" && t?.current ? (t.current.addEventListener("load", L), t.current.contentDocument?.readyState === "complete" && L()) : [200, 500, 1e3, 2e3, 3e3].forEach((n) => { + T.push(setTimeout($, n)); }), () => { - window.removeEventListener("message", w), L.forEach(clearTimeout), typeof t == "object" && t?.current && t.current.removeEventListener("load", R); + window.removeEventListener("message", k), T.forEach(clearTimeout), typeof t == "object" && t?.current && t.current.removeEventListener("load", L); }; }, [ a, @@ -182,15 +211,15 @@ function ee(e, t) { e.onPlaybackQualityChange, e.stopOnEnd, e.id, - l, + o, i, t - ]), /* @__PURE__ */ b(k, { children: [ - !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: h }), + ]), /* @__PURE__ */ y(E, { children: [ + !e.lazyLoad && /* @__PURE__ */ c("link", { rel: V, href: g, as: "image" }), + /* @__PURE__ */ c(E, { children: u && /* @__PURE__ */ y(E, { children: [ + /* @__PURE__ */ c("link", { rel: "preconnect", href: b }), /* @__PURE__ */ c("link", { rel: "preconnect", href: "https://www.google.com" }), - W && /* @__PURE__ */ b(k, { children: [ + W && /* @__PURE__ */ y(E, { children: [ /* @__PURE__ */ c("link", { rel: "preconnect", href: "https://static.doubleclick.net" }), /* @__PURE__ */ c( "link", @@ -209,14 +238,14 @@ function ee(e, t) { __html: p( e.id, i, - y, - h, + g, + b, e.seo ) } } ), - G && !e.playlist && /* @__PURE__ */ c("noscript", { children: /* @__PURE__ */ b( + H && !e.playlist && /* @__PURE__ */ c("noscript", { children: /* @__PURE__ */ y( "a", { href: `https://www.youtube.com/watch?v=${e.id}`, @@ -228,25 +257,25 @@ function ee(e, t) { ] } ) }), - /* @__PURE__ */ b( - V, + /* @__PURE__ */ y( + Y, { onPointerOver: J, - onClick: S, - className: `${Y} ${a ? B : ""}`, + onClick: O, + className: `${Q} ${a ? P : ""}`, "data-title": i, role: a ? void 0 : "img", "aria-label": a ? void 0 : `${i} - YouTube video preview`, style: { - ...!e.lazyLoad && { backgroundImage: `url(${y})` }, - "--aspect-ratio": `${j / x * 100}%`, + ...!e.lazyLoad && { backgroundImage: `url(${g})` }, + "--aspect-ratio": `${B / j * 100}%`, ...e.style || {} }, children: [ e.lazyLoad && !a && /* @__PURE__ */ c( "img", { - src: y, + src: g, alt: `${i} - YouTube thumbnail`, className: "lty-thumbnail", loading: "lazy" @@ -257,24 +286,24 @@ function ee(e, t) { { type: "button", className: F, - "aria-label": `${U} ${i}`, + "aria-label": `${N} ${i}`, "aria-hidden": a || void 0, tabIndex: a ? -1 : 0, - onClick: S, - children: /* @__PURE__ */ c("span", { className: "lty-visually-hidden", children: U }) + onClick: O, + children: /* @__PURE__ */ c("span", { className: "lty-visually-hidden", children: N }) } ), a && /* @__PURE__ */ c( "iframe", { ref: t, - className: P, + className: x, title: i, width: "560", height: "315", allow: "accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture", allowFullScreen: !0, - src: _, + src: M, referrerPolicy: e.referrerPolicy || "strict-origin-when-cross-origin" } ) @@ -283,7 +312,7 @@ function ee(e, t) { ) ] }); } -const ne = o.forwardRef( +const ne = l.forwardRef( ee ); export { diff --git a/dist/index.es.js.map b/dist/index.es.js.map index abda671..34c3db2 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