diff --git a/.babelrc.js b/.babelrc.js deleted file mode 100644 index 339063f7..00000000 --- a/.babelrc.js +++ /dev/null @@ -1,20 +0,0 @@ -module.exports = (api) => { - api.cache.never(); - - const envOptions = { - modules: false, - loose: true, - }; - - if (process.env.NODE_ENV === 'test') { - envOptions.modules = 'commonjs'; - envOptions.targets = { node: 'current' }; - } - - return { - presets: [ - ['@babel/env', envOptions], - '@babel/react', - ], - }; -}; diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 00000000..e94f8140 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1 @@ +defaults diff --git a/.eslintrc.js b/.eslintrc.js index 0d954cc6..b614cc4d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,12 +1,11 @@ module.exports = { extends: 'airbnb', + parserOptions: { + ecmaVersion: 2021, + }, rules: { - // I disagree - 'react/jsx-filename-extension': 'off', // I disagree 'react/require-default-props': 'off', - // Our babel config doesn't support class properties - 'react/state-in-constructor': 'off', // I disagree 'react/function-component-definition': ['error', { namedComponents: 'function-declaration', @@ -21,4 +20,20 @@ module.exports = { allowChildren: false, }], }, + overrides: [ + { + files: ['**/*.ts', '**/*.tsx'], + extends: ['airbnb-typescript'], + parserOptions: { + project: './tsconfig.json', + }, + }, + { + files: ['example/**/*.ts', 'example/**/*.tsx'], + extends: ['airbnb-typescript'], + parserOptions: { + project: './example/tsconfig.json', + }, + }, + ], }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce8a96f7..ebed69b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,21 +3,6 @@ name: CI on: [push, pull_request] jobs: - types: - name: Types - runs-on: ubuntu-latest - steps: - - name: Checkout sources - uses: actions/checkout@v4 - - name: Install Node.js - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm install - - name: Check types - run: npm run tsd - lint: name: Code style runs-on: ubuntu-latest @@ -37,11 +22,8 @@ jobs: name: Tests strategy: matrix: - node-version: [16.x, 18.x, 20.x] - react-version: [17.x, 18.x] - include: - - node-version: 12.x - react-version: 16.0.0 + node-version: [18.x, 20.x, 22.x] + react-version: [17.x, 18.x, 19.x] runs-on: ubuntu-latest steps: - name: Checkout sources @@ -55,8 +37,10 @@ jobs: - name: Install React ${{matrix.react-version}} if: matrix.react-version != '18.x' run: | - npm install --save-dev \ + npm install --force --save-dev \ react@${{matrix.react-version}} \ react-dom@${{matrix.react-version}} + - name: Build module + run: npm run build - name: Run tests run: npm run tests-only diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5fc77dda..fccd0e99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,17 +17,16 @@ jobs: run: npm install - name: Run tests run: npm test + - name: Build module + run: npm run build - name: Build example - run: | - npm run --prefix example build - mkdir _deploy - cp example/bundle.js example/index.html _deploy + run: npm run --prefix example build - name: Publish site if: success() uses: crazy-max/ghaction-github-pages@v4 with: target_branch: gh-pages - build_dir: _deploy + build_dir: example/dist keep_history: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index be14f8b0..e3d54a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## 1.0.0-alpha.6 - 2024-12-26 + * Allow React 19 in peer dependencies range. + +## 1.0.0-alpha.5 - 2024-12-26 + * Use TypeScript for source code. + * Support React 19. + +## 1.0.0-alpha.4 - 2022-05-03 + * Add docs for the `useYouTube` hook. + * Add props for `origin` / `host` settings. + * Pass-through `muted` to the player initially, so `` works as expected. + +## 1.0.0-alpha.3 - 2022-05-01 + * Fix unmount order. + * Start player synchronously if the SDK is already loaded. + +## 1.0.0-alpha.2 - 2022-05-01 + * Expose all functionality as a `useYouTube` hook. + * Remove props no longer supported by YouTube: `showInfo`, `suggestedQuality`. + +## 1.0.0-alpha.1 - 2022-04-20 + * Improve typings. + * Remove duplicate `defaultProps`. + +## 1.0.0-alpha.0 - 2021-12-01 + * Use hooks internally. + * Drop support for React 16. This version requires React 17 or 18. + * Target evergreen browsers. If you need to support older browsers, you need to transpile this dependency. + ## 0.7.4 - 2022-04-23 * Fix a warning about workspaces when installing with yarn. diff --git a/README.md b/README.md index 20336912..74be86e9 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@ # @u-wave/react-youtube - YouTube player component for React. -[Install][] - [Usage][] - [Demo][] - [Props][] +[Install][] - [Usage][] - [Demo][] - [Component API][] - [Hook API][] ## Install - ``` -npm install --save @u-wave/react-youtube +npm install @u-wave/react-youtube ``` ## Usage - [Demo][] - [Demo source code][] ```js @@ -23,15 +20,23 @@ import YouTube from '@u-wave/react-youtube'; /> ``` -## Props + +## `` +The `` component renders an iframe and attaches the YouTube player to it. It supports all the +same options as the `useYouTube` hook, plus a few to configure the iframe. If you need to do more with +the iframe than this component provides, consider using the `useYouTube` hook directly. + +### Props | Name | Type | Default | Description | |:-----|:-----|:-----|:-----| -| video | string | | An 11-character string representing a YouTube video ID.. | | id | string | | DOM ID for the player element. | | className | string | | CSS className for the player element. | | style | object | | Inline style for container element. | +| video | string | | An 11-character string representing a YouTube video ID.. | | width | number, string | | Width of the player element. | | height | number, string | | Height of the player element. | +| host | string | https://www.youtube.com | YouTube host to use: 'https://www.youtube.com' or 'https://www.youtube-nocookie.com'. | +| origin | string | | The YouTube API will usually default this value correctly. It is exposed for completeness.
https://developers.google.com/youtube/player_parameters#origin | | paused | bool | | Pause the video. | | autoplay | bool | false | Whether the video should start playing automatically.
https://developers.google.com/youtube/player_parameters#autoplay | | showCaptions | bool | false | Whether to show captions below the video.
https://developers.google.com/youtube/player_parameters#cc_load_policy | @@ -47,31 +52,84 @@ import YouTube from '@u-wave/react-youtube'; | showRelatedVideos | bool | true | Whether to show related videos after the video is over.
https://developers.google.com/youtube/player_parameters#rel | | volume | number | | The playback volume, **as a number between 0 and 1**. | | muted | bool | | Whether the video's sound should be muted. | -| suggestedQuality | string | | The suggested playback quality.
https://developers.google.com/youtube/iframe_api_reference#Playback_quality | | playbackRate | number | | Playback speed.
https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate | | onReady | function | | Sent when the YouTube player API has loaded. | | onError | function | | Sent when the player triggers an error. | -| onCued | function | () => {} | Sent when the video is cued and ready to play. | -| onBuffering | function | () => {} | Sent when the video is buffering. | -| onPlaying | function | () => {} | Sent when playback has been started or resumed. | -| onPause | function | () => {} | Sent when playback has been paused. | -| onEnd | function | () => {} | Sent when playback has stopped. | +| onCued | function | | Sent when the video is cued and ready to play. | +| onBuffering | function | | Sent when the video is buffering. | +| onPlaying | function | | Sent when playback has been started or resumed. | +| onPause | function | | Sent when playback has been paused. | +| onEnd | function | | Sent when playback has stopped. | | onStateChange | function | | | | onPlaybackRateChange | function | | | | onPlaybackQualityChange | function | | | -## Related + +## `useYouTube(container, options)` +Create a YouTube player at `container`. `container` must be a ref object. + +Returns the `YT.Player` object, or `null` until the player is ready. +```js +import { useYouTube } from '@u-wave/react-youtube'; + +function Player() { + const container = useRef(null); + const player = useYouTube(container, { + video: 'x2to0hs', + autoplay: true, + }); + console.log(player?.getVideoUrl()); + return
; +} +``` + +### Options +| Name | Type | Default | Description | +|:-----|:-----|:-----|:-----| +| video | string | | An 11-character string representing a YouTube video ID.. | +| width | number, string | | Width of the player element. | +| height | number, string | | Height of the player element. | +| host | string | https://www.youtube.com | YouTube host to use: 'https://www.youtube.com' or 'https://www.youtube-nocookie.com'. | +| origin | string | | The YouTube API will usually default this value correctly. It is exposed for completeness.
https://developers.google.com/youtube/player_parameters#origin | +| paused | bool | | Pause the video. | +| autoplay | bool | false | Whether the video should start playing automatically.
https://developers.google.com/youtube/player_parameters#autoplay | +| showCaptions | bool | false | Whether to show captions below the video.
https://developers.google.com/youtube/player_parameters#cc_load_policy | +| controls | bool | true | Whether to show video controls.
https://developers.google.com/youtube/player_parameters#controls | +| disableKeyboard | bool | false | Ignore keyboard controls.
https://developers.google.com/youtube/player_parameters#disablekb | +| allowFullscreen | bool | true | Whether to display the fullscreen button.
https://developers.google.com/youtube/player_parameters#fs | +| lang | string | | The player's interface language. The parameter value is an ISO 639-1 two-letter language code or a fully specified locale.
https://developers.google.com/youtube/player_parameters#hl | +| annotations | bool | true | Whether to show annotations on top of the video.
https://developers.google.com/youtube/player_parameters#iv_load_policy | +| startSeconds | number | | Time in seconds at which to start playing the video.
https://developers.google.com/youtube/player_parameters#start | +| endSeconds | number | | Time in seconds at which to stop playing the video.
https://developers.google.com/youtube/player_parameters#end | +| modestBranding | bool | false | Remove most YouTube logos from the player.
https://developers.google.com/youtube/player_parameters#modestbranding | +| playsInline | bool | false | Whether to play the video inline on iOS, instead of fullscreen.
https://developers.google.com/youtube/player_parameters#playsinline | +| showRelatedVideos | bool | true | Whether to show related videos after the video is over.
https://developers.google.com/youtube/player_parameters#rel | +| volume | number | | The playback volume, **as a number between 0 and 1**. | +| muted | bool | | Whether the video's sound should be muted. | +| playbackRate | number | | Playback speed.
https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate | +| onReady | function | | Sent when the YouTube player API has loaded. | +| onError | function | | Sent when the player triggers an error. | +| onCued | function | | Sent when the video is cued and ready to play. | +| onBuffering | function | | Sent when the video is buffering. | +| onPlaying | function | | Sent when playback has been started or resumed. | +| onPause | function | | Sent when playback has been paused. | +| onEnd | function | | Sent when playback has stopped. | +| onStateChange | function | | | +| onPlaybackRateChange | function | | | +| onPlaybackQualityChange | function | | | + +## Related - [react-youtube][] - A widely-used YouTube component. Its API matches the YouTube iframe API more closely, and it doesn't support prop-based volume/quality/playback changes. - [@u-wave/react-vimeo][] - A Vimeo component with a similar declarative API. ## License - [MIT][] [Install]: #install [Usage]: #usage -[Props]: #props +[Component API]: #component +[Hook API]: #hook [Demo]: https://u-wave.net/react-youtube [Demo source code]: ./example [MIT]: ./LICENSE diff --git a/example/.gitignore b/example/.gitignore index 0e804e3a..1521c8b7 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1 +1 @@ -bundle.js +dist diff --git a/example/app.js b/example/app.js deleted file mode 100644 index 0429d91d..00000000 --- a/example/app.js +++ /dev/null @@ -1,132 +0,0 @@ -/* eslint-env browser */ -import React from 'react'; -import ReactDOM from 'react-dom'; -import YouTube from '@u-wave/react-youtube'; // eslint-disable-line import/no-unresolved - -const { - useCallback, - useState, -} = React; - -const videos = [ - { id: 'ZuuVjuLNvFY', name: 'JUNNY - kontra (Feat. Lil Gimch, Keeflow)' }, - { id: 'PYE7jXNjFWw', name: 'T W L V - Follow' }, - { id: 'ld8ugY47cps', name: 'SLCHLD - I can\'t love you anymore' }, - { id: null, name: '' }, -]; - -const qualities = ['auto', '240', '380', '480', '720', '1080', '1440', '2160']; - -const hashVideoRx = /^#!\/video\/(\d)$/; -const hash = typeof window.location !== 'undefined' - ? window.location.hash : ''; // eslint-disable-line no-undef -const defaultVideo = hashVideoRx.test(hash) - ? parseInt(hash.replace(hashVideoRx, '$1'), 10) - : 0; - -function App() { - const [videoIndex, setVideoIndex] = useState(defaultVideo); - const [suggestedQuality, setSuggestedQuality] = useState('auto'); - const [volume, setVolume] = useState(1); - const [paused, setPaused] = useState(false); - - const video = videos[videoIndex]; - - function selectVideo(index) { - setVideoIndex(index); - } - - const handlePause = useCallback((event) => { - setPaused(event.target.checked); - }, []); - - const handlePlayerPause = useCallback(() => { - setPaused(true); - }, []); - - const handlePlayerPlay = useCallback(() => { - setPaused(false); - }, []); - - const handleVolume = useCallback((event) => { - setVolume(parseFloat(event.target.value)); - }, []); - - const handleQuality = useCallback((event) => { - setSuggestedQuality(qualities[event.target.selectedIndex]); - }, []); - - return ( -
-
-
- Video -
-
- {videos.map((choice, index) => ( - selectVideo(index)} - > - {choice.name} - - ))} -
-
- Paused -
-

- -

-
- Volume -
- -
- Quality -
- -
-
- -
-
- ); -} - -// eslint-disable-next-line react/no-deprecated -ReactDOM.render(, document.getElementById('example')); diff --git a/example/app.tsx b/example/app.tsx new file mode 100644 index 00000000..c94bdd2c --- /dev/null +++ b/example/app.tsx @@ -0,0 +1,273 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { styled } from '@stitches/react'; +import YouTube from '@u-wave/react-youtube'; + +const { + useCallback, + useState, +} = React; + +const Link = styled('a', { + color: '#c72e6c', + textDecoration: 'underline', + '&:visited': { color: '#c72e6c' }, +}); +const Label = styled('label', { + cursor: 'pointer', + fontSize: '1rem', + '& input': { marginRight: '.5rem' }, +}); +const H1 = styled('h1', { + fontWeight: 400, + fontSize: '3rem', +}); +const H5 = styled('h5', { + margin: '1.0933333333rem 0 .656rem 0', + fontWeight: 400, + fontSize: '1.64rem', + lineHeight: '110%', +}); +const Input = styled('input', { + '&[type="range"]': { + background: 'transparent', + appearance: 'none', + cursor: 'pointer', + margin: '1rem 0', + padding: 0, + outline: 'none', + width: '16rem', + accentColor: '#9d2053', + + '&::-moz-range-track, &::-webkit-slider-runnable-track': { + height: 2, + backgroundColor: 'rgba(255, 255, 255, 0.38)', + }, + '&::-moz-range-thumb, &::-webkit-slider-thumb': { + border: 'none', + width: 14, + height: 14, + background: '#9d2053', + marginTop: -5, + borderRadius: 9999, + }, + }, +}); + +const Nav = styled('nav', { + backgroundColor: '#9d2053', + color: 'white', + padding: '0 2rem', + display: 'flex', + alignItems: 'center', + height: '64px', + gap: '2rem', +}); +const NavButton = styled('a', { + color: 'white', + padding: '.75rem', + borderRadius: '4px', + textTransform: 'uppercase', + textDecoration: 'none', + fontWeight: 500, + fontSize: '0.875rem', + lineHeight: '1.75', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.08)', + }, +}); + +const LogoLink = styled('a', { + height: '48px', + lineHeight: '48px', + '& img': { height: 48 }, +}); + +const List = styled('ul', { + listStyle: 'none', + margin: 0, + padding: 0, + display: 'flex', + variants: { + direction: { + vertical: { flexDirection: 'column' }, + horizontal: { flexDirection: 'row' }, + }, + }, + defaultVariants: { + direction: 'vertical', + }, +}); +const ListItem = styled('li', { + margin: 0, + padding: '0 1rem', + fontSize: '1rem', + color: 'white', + height: '48px', + display: 'flex', + alignItems: 'center', + textDecoration: 'none', + '&:hover': { + backgroundColor: 'rgba(255, 255, 255, 0.08)', + }, + variants: { + active: { + true: { + backgroundColor: 'rgba(48, 48, 54, 0.3)', + }, + }, + }, +}); + +const Layout = styled('main', { + display: 'grid', + gridTemplateAreas: '"header header" "options embed"', + gridTemplateColumns: '24rem auto', + gridGap: '1rem', + margin: 'auto', + width: 'min-content', + '@media (max-width: 800px)': { + gridTemplateAreas: '"header" "embed" "options"', + gridTemplateColumns: 'auto', + }, +}); +const Header = styled('div', { + gridArea: 'header', +}); +const Options = styled('div', { + gridArea: 'options', +}); +const Embed = styled('div', { + gridArea: 'embed', +}); + +const videos = [ + { id: 'ZuuVjuLNvFY', name: 'JUNNY - kontra (Feat. Lil Gimch, Keeflow)' }, + { id: 'PYE7jXNjFWw', name: 'T W L V - Follow' }, + { id: 'ld8ugY47cps', name: 'SLCHLD - I can\'t love you anymore' }, + { id: null, name: '' }, +]; + +function getInitialVideo() { + const hashVideoRx = /^#!\/video\/(\d)$/; + const hash = typeof window.location !== 'undefined' ? window.location.hash : ''; + return hashVideoRx.test(hash) + ? parseInt(hash.replace(hashVideoRx, '$1'), 10) + : 0; +} + +function Example() { + const [videoIndex, setVideoIndex] = useState(getInitialVideo); + const [volume, setVolume] = useState(1); + const [paused, setPaused] = useState(false); + + const video = videos[videoIndex]; + + const handlePause = useCallback((event: React.ChangeEvent) => { + setPaused(event.target.checked); + }, []); + + const handlePlayerPause = useCallback(() => { + setPaused(true); + }, []); + + const handlePlayerPlay = useCallback(() => { + setPaused(false); + }, []); + + const handleVolume = useCallback((event: React.ChangeEvent) => { + setVolume(parseFloat(event.target.value)); + }, []); + + return ( + <> + +
Video
+ + {videos.map((choice, index) => ( + setVideoIndex(index)} + active={videoIndex === index} + > + {choice.name} + + ))} + +
Paused
+

+ +

+
Volume
+ +
+ + + + + ); +} + +function App() { + return ( + <> + + +
+

@u-wave/react-youtube example

+

+ An example YouTube player using + {' '} + React + {' '} + and + @u-wave/react-youtube + . + {' '} + view source +

+
+ +
+ + ); +} + +const root = createRoot(document.getElementById('example')); +root.render(); diff --git a/example/index.html b/example/index.html index e7dd8331..55d08e31 100644 --- a/example/index.html +++ b/example/index.html @@ -3,45 +3,18 @@ @u-wave/react-youtube example - - -
-
-
-

@u-wave/react-youtube example

-

- An example YouTube player using React - and @u-wave/react-youtube. - view source -

-
-
-
-
- +
+ diff --git a/example/package.json b/example/package.json index dd183ce3..50f597a9 100644 --- a/example/package.json +++ b/example/package.json @@ -5,14 +5,15 @@ "version": "0.0.0-example", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "build": "esbuild --bundle app.js --loader:.js=jsx > bundle.js", - "start": "serve ." + "build": "vite build", + "start": "vite" }, "dependencies": { + "@stitches/react": "^1.2.8", "@u-wave/react-youtube": "file:..", - "esbuild": "^0.14.0", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "serve": "^13.0.0" + "@vitejs/plugin-react": "^4.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.0.1" } } diff --git a/example/tsconfig.json b/example/tsconfig.json new file mode 100644 index 00000000..43d8a903 --- /dev/null +++ b/example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["dom", "es2020"], + "jsx": "react-jsx", + "noEmit": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["*.tsx", "*.ts"] +} diff --git a/example/vite.config.mts b/example/vite.config.mts new file mode 100644 index 00000000..0466183a --- /dev/null +++ b/example/vite.config.mts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index d2c99e89..00000000 --- a/index.d.ts +++ /dev/null @@ -1,173 +0,0 @@ -/// -import * as React from 'react' - -export interface YouTubeProps { - /** - * An 11-character string representing a YouTube video ID.. - */ - video?: string; - /** - * DOM ID for the player element. - */ - id?: string; - /** - * CSS className for the player element. - */ - className?: string; - /** - * Inline style for container element. - */ - style?: React.CSSProperties; - /** - * Width of the player element. - */ - width?: number | string; - /** - * Height of the player element. - */ - height?: number | string; - - /** - * Pause the video. - */ - paused?: boolean; - - // Player parameters - - /** - * Whether the video should start playing automatically. - * - * https://developers.google.com/youtube/player_parameters#autoplay - */ - autoplay?: boolean; - /** - * Whether to show captions below the video. - * - * https://developers.google.com/youtube/player_parameters#cc_load_policy - */ - showCaptions?: boolean; - /** - * Whether to show video controls. - * - * https://developers.google.com/youtube/player_parameters#controls - */ - controls?: boolean; - /** - * Ignore keyboard controls. - * - * https://developers.google.com/youtube/player_parameters#disablekb - */ - disableKeyboard?: boolean; - /** - * Whether to display the fullscreen button. - * - * https://developers.google.com/youtube/player_parameters#fs - */ - allowFullscreen?: boolean; - /** - * The player's interface language. The parameter value is an ISO 639-1 - * two-letter language code or a fully specified locale. - * - * https://developers.google.com/youtube/player_parameters#hl - */ - lang?: string; - /** - * Whether to show annotations on top of the video. - * - * https://developers.google.com/youtube/player_parameters#iv_load_policy - */ - annotations?: boolean; - /** - * Time in seconds at which to start playing the video. - * - * https://developers.google.com/youtube/player_parameters#start - */ - startSeconds?: number; - /** - * Time in seconds at which to stop playing the video. - * - * https://developers.google.com/youtube/player_parameters#end - */ - endSeconds?: number; - /** - * Remove most YouTube logos from the player. - * - * https://developers.google.com/youtube/player_parameters#modestbranding - */ - modestBranding?: boolean; - /** - * Whether to play the video inline on iOS, instead of fullscreen. - * - * https://developers.google.com/youtube/player_parameters#playsinline - */ - playsInline?: boolean; - /** - * Whether to show related videos after the video is over. - * - * https://developers.google.com/youtube/player_parameters#rel - */ - showRelatedVideos?: boolean; - /** - * @deprecated This property was removed from the YouTube API. - */ - showInfo?: boolean; - - /** - * The playback volume, **as a number between 0 and 1**. - */ - volume?: number; - - /** - * Whether the video's sound should be muted. - */ - muted?: boolean; - - /** - * The suggested playback quality. - * - * https://developers.google.com/youtube/iframe_api_reference#Playback_quality - */ - suggestedQuality?: string; - /** - * Playback speed. - * - * https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate - */ - playbackRate?: number; - - // Events - - /** - * Sent when the YouTube player API has loaded. - */ - onReady?: YT.PlayerEventHandler; - /** - * Sent when the player triggers an error. - */ - onError?: YT.PlayerEventHandler; - /** - * Sent when the video is cued and ready to play. - */ - onCued?: YT.PlayerEventHandler; - /** - * Sent when the video is buffering. - */ - onBuffering?: YT.PlayerEventHandler; - /** - * Sent when playback has been started or resumed. - */ - onPlaying?: YT.PlayerEventHandler; - /** - * Sent when playback has been paused. - */ - onPause?: YT.PlayerEventHandler; - /** - * Sent when playback has stopped. - */ - onEnd?: YT.PlayerEventHandler; - onStateChange?: YT.PlayerEventHandler; - onPlaybackRateChange?: YT.PlayerEventHandler; - onPlaybackQualityChange?: YT.PlayerEventHandler; -} - -export default class YouTube extends React.Component {} diff --git a/index.test-d.ts b/index.test-d.ts deleted file mode 100644 index 6d549a4c..00000000 --- a/index.test-d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react' -import YouTube from '.' - -{ - React.createElement(YouTube) -} - -{ - let onCued = (event: YT.OnStateChangeEvent) => { - if (event.data === 5) {} - } - React.createElement(YouTube, { onCued }) -} - -{ - React.createElement(YouTube, { video: 'Mf9oZPwO6js', width: 600, height: '300px' }) -} - -{ - function onReady ({ target }: YT.PlayerEvent): void { - target.getIframe() - } - React.createElement(YouTube, { showCaptions: true, onReady }) -} - -{ - React.createElement(YouTube, { - autoplay: true, - onReady({ target }) { target.getIframe() } - }) -} diff --git a/package.json b/package.json index 02fd816d..5b15e8ef 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,37 @@ { "name": "@u-wave/react-youtube", "description": "YouTube player component for React.", - "version": "0.7.4", + "version": "1.0.0-alpha.6", "author": "Renée Kooi ", "bugs": { "url": "https://github.com/u-wave/react-youtube/issues" }, "dependencies": { - "@types/react": "^17.0.0", - "@types/youtube": "0.0.50", - "load-script2": "^1.0.1", - "prop-types": "^15.7.2" + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/youtube": "^0.1.0" }, "devDependencies": { - "@babel/core": "^7.8.3", - "@babel/plugin-transform-modules-commonjs": "^7.8.3", - "@babel/preset-env": "^7.8.3", - "@babel/preset-react": "^7.8.3", - "@babel/register": "^7.8.3", - "@rollup/plugin-babel": "^6.0.0", + "@microsoft/api-extractor": "^7.48.1", + "@types/node": "^18.11.14", + "@types/react-dom": "^19.0.2", + "@typescript-eslint/eslint-plugin": "^5.46.1", + "@typescript-eslint/parser": "^5.46.1", "@u-wave/react-youtube-example": "file:example", - "cross-env": "^7.0.0", "eslint": "^8.2.0", "eslint-config-airbnb": "^19.0.0", + "eslint-config-airbnb-typescript": "^17.0.0", "eslint-plugin-import": "^2.22.0", "eslint-plugin-jsx-a11y": "^6.3.1", "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^4.0.5", - "expect": "^1.20.2", - "md-insert": "^2.0.0", + "md-insert": "^1.0.1", "min-react-env": "^2.0.0", - "mocha": "^10.0.0", "prop-types-table": "^1.0.0", - "proxyquire": "^2.1.3", "react": "^18.0.0", "react-dom": "^18.0.0", - "rollup": "^2.0.2", - "tsd": "^0.31.0" + "tsup": "^8.3.5", + "typescript": "5.4.2", + "vitest": "^0.29.8" }, "homepage": "https://github.com/u-wave/react-youtube#readme", "keywords": [ @@ -47,25 +42,31 @@ "youtube" ], "license": "MIT", - "main": "dist/react-youtube.js", - "module": "dist/react-youtube.es.js", - "types": "index.d.ts", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "repository": { "type": "git", "url": "git+https://github.com/u-wave/react-youtube.git" }, "scripts": { - "build": "rollup -c", + "build": "tsup src/index.tsx --format esm,cjs && tsc --declaration --emitDeclarationOnly -p .", "docs": "prop-types-table src/index.js | md-insert README.md --header Props -i", - "example": "npm run --prefix example build && npm run --prefix example start", - "prepare": "npm run build", - "test": "npm run tsd && npm run tests-only && npm run lint", - "lint": "eslint --cache --fix .", - "tests-only": "cross-env NODE_ENV=test mocha --require @babel/register test/*.js", - "tsd": "tsd" + "example": "npm run --prefix example start", + "browserslist": "npx browserslist --mobile-to-desktop '> 0.5%, last 2 versions, Firefox ESR, not dead, not IE 11' > .browserslistrc", + "test": "npm run tests-only && npm run lint", + "lint": "eslint --fix --ext .js,.jsx,.ts,.tsx .", + "tests-only": "vitest run" }, "sideEffects": false } diff --git a/rollup.config.js b/rollup.config.js deleted file mode 100644 index 3d3889c8..00000000 --- a/rollup.config.js +++ /dev/null @@ -1,19 +0,0 @@ -import babel from '@rollup/plugin-babel'; - -const meta = require('./package.json'); - -export default { - input: './src/index.js', - output: [ - { format: 'cjs', file: meta.main, exports: 'named' }, - { format: 'es', file: meta.module }, - ], - - external: Object.keys(meta.dependencies) - .concat(Object.keys(meta.peerDependencies)), - plugins: [ - babel({ - babelHelpers: 'bundled', - }), - ], -}; diff --git a/src/eventNames.js b/src/eventNames.js deleted file mode 100644 index 0325380f..00000000 --- a/src/eventNames.js +++ /dev/null @@ -1,8 +0,0 @@ -export default [ - 'onReady', - 'onStateChange', - 'onPlaybackQualityChange', - 'onPlaybackRateChange', - 'onError', - 'onApiChange', -]; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index a9bc6b2d..00000000 --- a/src/index.js +++ /dev/null @@ -1,434 +0,0 @@ -import * as React from 'react'; -import PropTypes from 'prop-types'; -import eventNames from './eventNames'; -import loadSdk from './loadSdk'; - -class YouTube extends React.Component { - constructor(props) { - super(props); - - this.onPlayerReady = this.onPlayerReady.bind(this); - this.onPlayerStateChange = this.onPlayerStateChange.bind(this); - this.refContainer = this.refContainer.bind(this); - } - - componentDidMount() { - this.createPlayer(); - } - - componentDidUpdate(prevProps) { - // eslint-disable-next-line react/destructuring-assignment - const changes = Object.keys(this.props).filter((name) => this.props[name] !== prevProps[name]); - - this.updateProps(changes); - } - - componentWillUnmount() { - if (this.playerInstance) { - this.playerInstance.destroy(); - } - } - - onPlayerReady(event) { - const { - volume, - muted, - suggestedQuality, - playbackRate, - } = this.props; - - if (typeof volume !== 'undefined') { - event.target.setVolume(volume * 100); - } - if (typeof muted !== 'undefined') { - if (muted) { - event.target.mute(); - } else { - event.target.unMute(); - } - } - if (typeof suggestedQuality !== 'undefined') { - event.target.setPlaybackQuality(suggestedQuality); - } - if (typeof playbackRate !== 'undefined') { - event.target.setPlaybackRate(playbackRate); - } - - this.resolvePlayer(event.target); - } - - onPlayerStateChange(event) { - const { - onCued, - onBuffering, - onPause, - onPlaying, - onEnd, - } = this.props; - - const State = YT.PlayerState; // eslint-disable-line no-undef - switch (event.data) { - case State.CUED: - onCued(event); - break; - case State.BUFFERING: - onBuffering(event); - break; - case State.PAUSED: - onPause(event); - break; - case State.PLAYING: - onPlaying(event); - break; - case State.ENDED: - onEnd(event); - break; - default: - // Nothing - } - } - - /** - * @private - */ - getPlayerParameters() { - /* eslint-disable react/destructuring-assignment */ - return { - autoplay: this.props.autoplay, - cc_load_policy: this.props.showCaptions ? 1 : 0, - controls: this.props.controls ? 1 : 0, - disablekb: this.props.disableKeyboard ? 1 : 0, - fs: this.props.allowFullscreen ? 1 : 0, - hl: this.props.lang, - iv_load_policy: this.props.annotations ? 1 : 3, - start: this.props.startSeconds, - end: this.props.endSeconds, - modestbranding: this.props.modestBranding ? 1 : 0, - playsinline: this.props.playsInline ? 1 : 0, - rel: this.props.showRelatedVideos ? 1 : 0, - }; - /* eslint-enable react/destructuring-assignment */ - } - - /** - * @private - */ - getInitialOptions() { - /* eslint-disable react/destructuring-assignment */ - return { - videoId: this.props.video, - width: this.props.width, - height: this.props.height, - playerVars: this.getPlayerParameters(), - events: { - onReady: this.onPlayerReady, - onStateChange: this.onPlayerStateChange, - }, - }; - /* eslint-enable react/destructuring-assignment */ - } - - /** - * @private - */ - updateProps(propNames) { - this.player.then((player) => { - propNames.forEach((name) => { - // eslint-disable-next-line react/destructuring-assignment - const value = this.props[name]; - switch (name) { - case 'muted': - if (value) { - player.mute(); - } else { - player.unMute(); - } - break; - case 'suggestedQuality': - player.setPlaybackQuality(value); - break; - case 'volume': - player.setVolume(value * 100); - break; - case 'paused': - if (value && player.getPlayerState() !== 2) { - player.pauseVideo(); - } else if (!value && player.getPlayerState() === 2) { - player.playVideo(); - } - break; - case 'id': - case 'className': - case 'width': - case 'height': - player.getIframe()[name] = value; // eslint-disable-line no-param-reassign - break; - case 'video': - if (!value) { - player.stopVideo(); - } else { - const { startSeconds, endSeconds, autoplay } = this.props; - const opts = { - videoId: value, - startSeconds: startSeconds || 0, - endSeconds, - }; - if (autoplay) { - player.loadVideoById(opts); - } else { - player.cueVideoById(opts); - } - } - break; - default: - // Nothing - } - }); - }); - } - - /** - * @private - */ - createPlayer() { - const { volume } = this.props; - - this.player = loadSdk().then((YT) => new Promise((resolve) => { - this.resolvePlayer = resolve; - - const player = new YT.Player(this.container, this.getInitialOptions()); - // Store the instance directly so we can destroy it sync in - // `componentWillUnmount`. - this.playerInstance = player; - - eventNames.forEach((name) => { - player.addEventListener(name, (event) => { - // eslint-disable-next-line react/destructuring-assignment - const handler = this.props[name]; - if (handler) { - handler(event); - } - }); - }); - })); - - if (typeof volume === 'number') { - this.updateProps(['volume']); - } - } - - /** - * @private - */ - refContainer(container) { - this.container = container; - } - - render() { - const { id, className, style } = this.props; - - return ( -
- ); - } -} - -if (process.env.NODE_ENV !== 'production') { - YouTube.propTypes = { - /** - * An 11-character string representing a YouTube video ID.. - */ - video: PropTypes.string, - /** - * DOM ID for the player element. - */ - id: PropTypes.string, - /** - * CSS className for the player element. - */ - className: PropTypes.string, - /** - * Inline style for container element. - */ - style: PropTypes.object, // eslint-disable-line react/forbid-prop-types - /** - * Width of the player element. - */ - width: PropTypes.oneOfType([ - PropTypes.number, - PropTypes.string, - ]), - /** - * Height of the player element. - */ - height: PropTypes.oneOfType([ - PropTypes.number, - PropTypes.string, - ]), - - /** - * Pause the video. - */ - paused: PropTypes.bool, // eslint-disable-line react/no-unused-prop-types - - // Player parameters - - /** - * Whether the video should start playing automatically. - * - * https://developers.google.com/youtube/player_parameters#autoplay - */ - autoplay: PropTypes.bool, - /** - * Whether to show captions below the video. - * - * https://developers.google.com/youtube/player_parameters#cc_load_policy - */ - showCaptions: PropTypes.bool, - /** - * Whether to show video controls. - * - * https://developers.google.com/youtube/player_parameters#controls - */ - controls: PropTypes.bool, - /** - * Ignore keyboard controls. - * - * https://developers.google.com/youtube/player_parameters#disablekb - */ - disableKeyboard: PropTypes.bool, - /** - * Whether to display the fullscreen button. - * - * https://developers.google.com/youtube/player_parameters#fs - */ - allowFullscreen: PropTypes.bool, - /** - * The player's interface language. The parameter value is an ISO 639-1 - * two-letter language code or a fully specified locale. - * - * https://developers.google.com/youtube/player_parameters#hl - */ - lang: PropTypes.string, - /** - * Whether to show annotations on top of the video. - * - * https://developers.google.com/youtube/player_parameters#iv_load_policy - */ - annotations: PropTypes.bool, - /** - * Time in seconds at which to start playing the video. - * - * https://developers.google.com/youtube/player_parameters#start - */ - startSeconds: PropTypes.number, - /** - * Time in seconds at which to stop playing the video. - * - * https://developers.google.com/youtube/player_parameters#end - */ - endSeconds: PropTypes.number, - /** - * Remove most YouTube logos from the player. - * - * https://developers.google.com/youtube/player_parameters#modestbranding - */ - modestBranding: PropTypes.bool, - /** - * Whether to play the video inline on iOS, instead of fullscreen. - * - * https://developers.google.com/youtube/player_parameters#playsinline - */ - playsInline: PropTypes.bool, - /** - * Whether to show related videos after the video is over. - * - * https://developers.google.com/youtube/player_parameters#rel - */ - showRelatedVideos: PropTypes.bool, - - /** - * The playback volume, **as a number between 0 and 1**. - */ - volume: PropTypes.number, - - /** - * Whether the video's sound should be muted. - */ - muted: PropTypes.bool, - - /** - * The suggested playback quality. - * - * https://developers.google.com/youtube/iframe_api_reference#Playback_quality - */ - suggestedQuality: PropTypes.string, - /** - * Playback speed. - * - * https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate - */ - playbackRate: PropTypes.number, - - // Events - /* eslint-disable react/no-unused-prop-types */ - - /** - * Sent when the YouTube player API has loaded. - */ - onReady: PropTypes.func, - /** - * Sent when the player triggers an error. - */ - onError: PropTypes.func, - /** - * Sent when the video is cued and ready to play. - */ - onCued: PropTypes.func, - /** - * Sent when the video is buffering. - */ - onBuffering: PropTypes.func, - /** - * Sent when playback has been started or resumed. - */ - onPlaying: PropTypes.func, - /** - * Sent when playback has been paused. - */ - onPause: PropTypes.func, - /** - * Sent when playback has stopped. - */ - onEnd: PropTypes.func, - onStateChange: PropTypes.func, - onPlaybackRateChange: PropTypes.func, - onPlaybackQualityChange: PropTypes.func, - - /* eslint-enable react/no-unused-prop-types */ - }; -} - -YouTube.defaultProps = { - autoplay: false, - showCaptions: false, - controls: true, - disableKeyboard: false, - allowFullscreen: true, - annotations: true, - modestBranding: false, - playsInline: false, - showRelatedVideos: true, - onCued: () => {}, - onBuffering: () => {}, - onPlaying: () => {}, - onPause: () => {}, - onEnd: () => {}, -}; - -export default YouTube; diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 00000000..d5d15b92 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,420 @@ +import React from 'react'; +import loadSdk from './loadSdk'; + +const { + useCallback, + useEffect, + useRef, + useState, +} = React; +const useLayoutEffect = typeof document !== 'undefined' ? React.useLayoutEffect : useEffect; + +// Player state numbers are documented to be constants, so we can inline them. +const ENDED = 0; +const PLAYING = 1; +const PAUSED = 2; +const BUFFERING = 3; +const CUED = 5; + +export interface YouTubeOptions { + /** An 11-character string representing a YouTube video ID. */ + video?: string | null; + /** Width of the player element. */ + width?: number | string; + /** Height of the player element. */ + height?: number | string; + + /** + * YouTube host to use: 'https://www.youtube.com' or 'https://www.youtube-nocookie.com'. + * + * @default 'https://www.youtube.com' + */ + host?: string; + + /** The YouTube API will usually default this value correctly. It is exposed for completeness. */ + origin?: string; + + /** Pause the video. */ + paused?: boolean; + + // Player parameters + + /** + * Whether the video should start playing automatically. + * + * https://developers.google.com/youtube/player_parameters#autoplay + * + * @default false + */ + autoplay?: boolean; + /** + * Whether to show captions below the video. + * + * https://developers.google.com/youtube/player_parameters#cc_load_policy + * + * @default false + */ + showCaptions?: boolean; + /** + * Whether to show video controls. + * + * https://developers.google.com/youtube/player_parameters#controls + * + * @default true + */ + controls?: boolean; + /** + * Ignore keyboard controls. + * + * https://developers.google.com/youtube/player_parameters#disablekb + * + * @default false + */ + disableKeyboard?: boolean; + /** + * Whether to display the fullscreen button. + * + * https://developers.google.com/youtube/player_parameters#fs + * + * @default true + */ + allowFullscreen?: boolean; + /** + * The player's interface language. The parameter value is an ISO 639-1 + * two-letter language code or a fully specified locale. + * + * https://developers.google.com/youtube/player_parameters#hl + */ + lang?: string; + /** + * Whether to show annotations on top of the video. + * + * https://developers.google.com/youtube/player_parameters#iv_load_policy + * + * @default true + */ + annotations?: boolean; + /** + * Time in seconds at which to start playing the video. + * + * https://developers.google.com/youtube/player_parameters#start + */ + startSeconds?: number; + /** + * Time in seconds at which to stop playing the video. + * + * https://developers.google.com/youtube/player_parameters#end + */ + endSeconds?: number; + /** + * Remove most YouTube logos from the player. + * + * https://developers.google.com/youtube/player_parameters#modestbranding + * + * @default false + */ + modestBranding?: boolean; + /** + * Whether to play the video inline on iOS, instead of fullscreen. + * + * https://developers.google.com/youtube/player_parameters#playsinline + * + * @default false + */ + playsInline?: boolean; + /** + * Whether to show related videos after the video is over. + * + * https://developers.google.com/youtube/player_parameters#rel + * + * @default true + */ + showRelatedVideos?: boolean; + + /** The playback volume, **as a number between 0 and 1**. */ + volume?: number; + + /** Whether the video's sound should be muted. */ + muted?: boolean; + + /** + * Playback speed. + * + * https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate + */ + playbackRate?: number; + + /** Sent when the YouTube player API has loaded. */ + onReady?: YT.PlayerEventHandler; + /** Sent when the player triggers an error. */ + onError?: YT.PlayerEventHandler; + /** Sent when the video is cued and ready to play. */ + onCued?: YT.PlayerEventHandler; + /** Sent when the video is buffering. */ + onBuffering?: YT.PlayerEventHandler; + /** Sent when playback has been started or resumed. */ + onPlaying?: YT.PlayerEventHandler; + /** Sent when playback has been paused. */ + onPause?: YT.PlayerEventHandler; + /** Sent when playback has stopped. */ + onEnd?: YT.PlayerEventHandler; + onStateChange?: YT.PlayerEventHandler; + onPlaybackRateChange?: YT.PlayerEventHandler; + onPlaybackQualityChange?: YT.PlayerEventHandler; +} + +export interface YouTubeProps extends YouTubeOptions { + /** + * DOM ID for the player element. + */ + id?: string; + /** + * CSS className for the player element. + */ + className?: string; + /** + * Inline style for player element. + */ + style?: React.CSSProperties; +} + +/** + * Attach an event listener to a YouTube player. + */ +function useEventHandler( + player: YT.Player | null, + event: K, + handler: YT.Events[K], +) { + useEffect(() => { + if (handler && player) { + player.addEventListener(event, handler); + } + return () => { + // If the iframe was already deleted, removing event + // listeners is unnecessary, and can actually cause a crash. + if (handler && player && player.getIframe()) { + player.removeEventListener(event, handler); + } + }; + }, [player, event, handler]); +} + +function getPlayerVars({ + startSeconds, + endSeconds, + lang, + muted = false, + autoplay = false, + showCaptions = false, + controls = true, + disableKeyboard = false, + allowFullscreen = true, + annotations = true, + modestBranding = false, + playsInline = false, + showRelatedVideos = true, + origin = typeof window.location === 'object' ? window.location.origin : undefined, +}: YouTubeOptions): YT.PlayerVars { + return { + autoplay: autoplay ? 1 : 0, + cc_load_policy: showCaptions ? 1 : 0, + controls: controls ? 1 : 0, + disablekb: disableKeyboard ? 1 : 0, + fs: allowFullscreen ? 1 : 0, + hl: lang, + iv_load_policy: annotations ? 1 : 3, + start: startSeconds, + end: endSeconds, + modestbranding: modestBranding ? 1 : 0, + playsinline: playsInline ? 1 : 0, + rel: showRelatedVideos ? 1 : 0, + mute: muted ? 1 : 0, + origin, + }; +} + +function useYouTube(container: React.RefObject, options: YouTubeOptions) { + const { + video, + startSeconds, + endSeconds, + width, + height, + paused, + muted, + volume, + playbackRate, + autoplay = false, + onReady, + onError, + onStateChange, + onPlaybackQualityChange, + onPlaybackRateChange, + onCued = () => {}, + onBuffering = () => {}, + onPlaying = () => {}, + onPause = () => {}, + onEnd = () => {}, + } = options; + + // Storing the player in the very first hook makes it easier to + // find in React DevTools :) + const [player, setPlayer] = useState(null); + const createPlayer = useRef<() => YT.Player>(null); + const firstRender = useRef(true); + + // Stick the player initialisation in a ref so it has the most recent props values + // when it gets instantiated. + if (!player) { + createPlayer.current = () => new YT.Player(container.current, { + videoId: video, + width, + height, + host: options.host, + playerVars: getPlayerVars(options), + events: { + onReady: (event) => { + setPlayer(event.target); + }, + }, + }); + } + + useLayoutEffect(() => { + let instance: YT.Player | null = null; + let cancelled = false; + + loadSdk(() => { + if (!cancelled) { + instance = createPlayer.current(); + } + }); + + return () => { + cancelled = true; + // Destroying the player here means that some other hooks cannot access its methods anymore, + // so they do need to be careful in their unsubscribe effects. + // There isn't really a way around this aside from manually implementing parts of the + // `destroy()` method. + // It's tempting to just remove the iframe here just in time for React to move in, + // but we must use `.destroy()` to avoid memory leaks, since the YouTube SDK holds on + // to references to player objects globally. + instance?.destroy(); + }; + }, []); + + const handlePlayerStateChange = useCallback((event: YT.OnStateChangeEvent) => { + switch (event.data) { + case CUED: + onCued(event); + break; + case BUFFERING: + onBuffering(event); + break; + case PAUSED: + onPause(event); + break; + case PLAYING: + onPlaying(event); + break; + case ENDED: + onEnd(event); + break; + default: + // Nothing + } + }, [onCued, onBuffering, onPause, onPlaying, onEnd]); + + useEventHandler(player, 'onStateChange', handlePlayerStateChange); + useEventHandler(player, 'onReady', onReady); + useEventHandler(player, 'onStateChange', onStateChange); + useEventHandler(player, 'onPlaybackQualityChange', onPlaybackQualityChange); + useEventHandler(player, 'onPlaybackRateChange', onPlaybackRateChange); + useEventHandler(player, 'onError', onError); + + useEffect(() => { + // We pretend to be a bit smarter than the typescript definitions here, since + // YouTube teeeechnically supports strings like '100%' too. + player?.setSize(width as number, height as number); + }, [player, width, height]); + + useEffect(() => { + if (muted) { + player?.mute(); + } else { + player?.unMute(); + } + }, [player, muted]); + + useEffect(() => { + player?.setPlaybackRate(playbackRate); + }, [player, playbackRate]); + + useEffect(() => { + player?.setVolume(volume * 100); + }, [player, volume]); + + useEffect(() => { + if (!player) { + return; + } + if (paused && player.getPlayerState() !== 2) { + player.pauseVideo(); + } else if (!paused && player.getPlayerState() === 2) { + player.playVideo(); + } + }, [player, paused]); + + useEffect(() => { + if (!player) { + return; + } + + // Avoid calling a load() function when the player has just initialised, + // since it will already be up to date at that stage. + if (firstRender.current) { + firstRender.current = false; + return; + } + + if (!video) { + player.stopVideo(); + } else { + const opts = { + videoId: video, + startSeconds: startSeconds || 0, + endSeconds, + }; + if (autoplay) { + player.loadVideoById(opts); + } else { + player.cueVideoById(opts); + } + } + }, [player, video]); + + return player; +} + +function YouTube({ + id, + className, + style, + ...options +}: YouTubeProps) { + const container = useRef(null); + useYouTube(container, options); + + return ( +
+ ); +} + +export { useYouTube }; +export default YouTube; diff --git a/src/loadSdk.js b/src/loadSdk.js deleted file mode 100644 index 53245388..00000000 --- a/src/loadSdk.js +++ /dev/null @@ -1,32 +0,0 @@ -/* global window */ -import loadScript from 'load-script2'; - -function loadSdk() { - return new Promise((resolve, reject) => { - if (typeof window.YT === 'object' && typeof window.YT.ready === 'function') { - // A YouTube SDK is already loaded, so reuse that - window.YT.ready(() => { - resolve(window.YT); - }); - return; - } - - loadScript('https://www.youtube.com/iframe_api', (err) => { - if (err) { - reject(err); - } else { - window.YT.ready(() => { - resolve(window.YT); - }); - } - }); - }); -} - -let sdk = null; -export default function getSdk() { - if (!sdk) { - sdk = loadSdk(); - } - return sdk; -} diff --git a/src/loadSdk.ts b/src/loadSdk.ts new file mode 100644 index 00000000..d16bfcbe --- /dev/null +++ b/src/loadSdk.ts @@ -0,0 +1,37 @@ +declare global { + namespace YT { + function ready(callback: (value: typeof YT) => void): void; + } +} + +function loadSdk() { + return new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.async = true; + script.src = 'https://www.youtube.com/iframe_api'; + script.onload = () => { + script.onerror = null; + script.onload = null; + YT.ready(resolve); + }; + script.onerror = () => { + script.onerror = null; + script.onload = null; + reject(new Error('Could not load YouTube SDK')); + }; + + document.head.appendChild(script); + }); +} + +let sdk = null; +export default function getSdk(callback: () => void) { + if (typeof YT === 'object' && typeof YT.ready === 'function') { + // A YouTube SDK is already loaded, so reuse that + YT.ready(callback); + return; + } + + sdk ??= loadSdk(); + sdk.then(callback); +} diff --git a/test/test.js b/test/index.test.js similarity index 74% rename from test/test.js rename to test/index.test.js index fd88cd70..77c54b3e 100644 --- a/test/test.js +++ b/test/index.test.js @@ -1,13 +1,19 @@ -import expect from 'expect'; +import { + describe, it, afterEach, vi, expect, +} from 'vitest'; import render from './util/render'; describe('YouTube', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should create a YouTube player when mounted', async () => { const { sdkMock } = await render({ video: 'x2y5kyu', }); expect(sdkMock.Player).toHaveBeenCalled(); - expect(sdkMock.Player.calls[0].arguments[1]).toMatch({ videoId: 'x2y5kyu' }); + expect(sdkMock.Player.calls[0][1]).toMatchObject({ videoId: 'x2y5kyu' }); }); it('should load a different video when "video" prop changes', async () => { @@ -15,14 +21,14 @@ describe('YouTube', () => { video: 'x2y5kyu', }); expect(sdkMock.Player).toHaveBeenCalled(); - expect(sdkMock.Player.calls[0].arguments[1]).toMatch({ + expect(sdkMock.Player.calls[0][1]).toMatchObject({ videoId: 'x2y5kyu', }); await rerender({ video: 'x3pn5cb' }); expect(playerMock.cueVideoById).toHaveBeenCalled(); - expect(playerMock.cueVideoById.calls[0].arguments[0]).toMatch({ + expect(playerMock.cueVideoById.calls[0][0]).toMatchObject({ videoId: 'x3pn5cb', }); }); @@ -31,7 +37,7 @@ describe('YouTube', () => { const { sdkMock, playerMock, rerender } = await render({ video: 'ZuuVjuLNvFY', }); - expect(sdkMock.Player.calls[0].arguments[1]).toMatch({ + expect(sdkMock.Player.calls[0][1]).toMatchObject({ videoId: 'ZuuVjuLNvFY', }); @@ -49,7 +55,7 @@ describe('YouTube', () => { // Don't call `play` again when we were already playing await rerender({ paused: false }); - expect(playerMock.playVideo).toNotHaveBeenCalled(); + expect(playerMock.playVideo).not.toHaveBeenCalled(); await rerender({ paused: true }); expect(playerMock.pauseVideo).toHaveBeenCalled(); @@ -85,25 +91,13 @@ describe('YouTube', () => { expect(playerMock.mute).toHaveBeenCalled(); }); - it('should set the quality when the "suggestedQuality" prop changes', async () => { - const { playerMock, rerender } = await render({ - video: 'x2y5kyu', - suggestedQuality: 'default', - }); - - await rerender({ suggestedQuality: '720hd' }); - expect(playerMock.setPlaybackQuality).toHaveBeenCalledWith('720hd'); - await rerender({ suggestedQuality: 'highres' }); - expect(playerMock.setPlaybackQuality).toHaveBeenCalledWith('highres'); - }); - it('should set the iframe width/height using the width/height props', async () => { const { sdkMock, playerMock, rerender } = await render({ video: 'x2y5kyu', width: 640, height: 320, }); - expect(sdkMock.Player.calls[0].arguments[1]).toMatch({ + expect(sdkMock.Player.calls[0][1]).toMatchObject({ width: 640, height: 320, }); @@ -113,8 +107,7 @@ describe('YouTube', () => { height: 800, }); - expect(playerMock.getIframe().setWidth).toHaveBeenCalledWith('100%'); - expect(playerMock.getIframe().setHeight).toHaveBeenCalledWith(800); + expect(playerMock.setSize).toHaveBeenCalledWith('100%', 800); }); it('should respect start/endSeconds', async () => { @@ -124,7 +117,7 @@ describe('YouTube', () => { endSeconds: 60, }); - expect(sdkMock.Player.calls[0].arguments[1]).toMatch({ + expect(sdkMock.Player.calls[0][1]).toMatchObject({ videoId: 'pRKqlw0DaDI', playerVars: { start: 30, @@ -139,7 +132,7 @@ describe('YouTube', () => { }); expect(playerMock.cueVideoById).toHaveBeenCalled(); - expect(playerMock.cueVideoById.calls[0].arguments[0]).toMatch({ + expect(playerMock.cueVideoById.calls[0][0]).toMatchObject({ videoId: 'hlk7o5T56iw', startSeconds: 40, endSeconds: undefined, @@ -153,7 +146,7 @@ describe('YouTube', () => { await rerender({ video: 'x3pn5cb' }); expect(playerMock.cueVideoById).toHaveBeenCalled(); - expect(playerMock.loadVideoById).toNotHaveBeenCalled(); + expect(playerMock.loadVideoById).not.toHaveBeenCalled(); playerMock.cueVideoById.reset(); @@ -162,7 +155,7 @@ describe('YouTube', () => { video: 'r6534246435', }); - expect(playerMock.cueVideoById).toNotHaveBeenCalled(); + expect(playerMock.cueVideoById).not.toHaveBeenCalled(); expect(playerMock.loadVideoById).toHaveBeenCalled(); }); }); diff --git a/test/util/createYouTube.js b/test/util/createYouTube.js index 8dfce8cc..0b1d9b33 100644 --- a/test/util/createYouTube.js +++ b/test/util/createYouTube.js @@ -1,56 +1,52 @@ -import { createSpy } from 'expect'; -import proxyquire from 'proxyquire'; +import { vi } from 'vitest'; +import YouTube from '../../src/index.tsx'; + +vi.mock('../../src/loadSdk.ts', () => ({ + default(callback) { + setImmediate(() => callback(global.YT)); + }, +})); export default function createYouTube() { let isPaused = true; const iframeMock = { - setWidth: createSpy(), - setHeight: createSpy(), - setId: createSpy(), - setClassName: createSpy(), - set width(width) { - iframeMock.setWidth(width); - }, - set height(height) { - iframeMock.setHeight(height); - }, - set id(id) { - iframeMock.setId(id); - }, + setId: vi.fn(), + setClassName: vi.fn(), set className(className) { iframeMock.setClassName(className); }, }; const playerMock = { - addEventListener: createSpy().andCall((eventName, fn) => { - if (eventName === 'ready') fn(); + addEventListener: vi.fn((eventName, fn) => { + if (eventName === 'onReady') fn({ target: playerMock }); }), - mute: createSpy(), - unMute: createSpy(), - setVolume: createSpy(), - setPlaybackQuality: createSpy(), - setPlaybackRate: createSpy(), - loadVideoById: createSpy(), - cueVideoById: createSpy(), - playVideo: createSpy().andCall(() => { + removeEventListener: vi.fn(), + mute: vi.fn(), + unMute: vi.fn(), + setVolume: vi.fn(), + setPlaybackRate: vi.fn(), + loadVideoById: vi.fn(), + cueVideoById: vi.fn(), + playVideo: vi.fn(() => { isPaused = false; }), - pauseVideo: createSpy().andCall(() => { + pauseVideo: vi.fn(() => { isPaused = true; }), - stopVideo: createSpy(), + stopVideo: vi.fn(), getPlayerState() { return isPaused ? 2 : 1; }, getIframe() { return iframeMock; }, + setSize: vi.fn(), }; const sdkMock = { - Player: createSpy().andCall((container, options) => { + Player: vi.fn((container, options) => { isPaused = !options.playerVars.autoplay; if (options.events && options.events.onReady) { @@ -63,11 +59,7 @@ export default function createYouTube() { }), }; - const YouTube = proxyquire('../../src/index.js', { - './loadSdk': { - default: () => Promise.resolve(sdkMock), - }, - }).default; + global.YT = sdkMock; return { YouTube, sdkMock, playerMock }; } diff --git a/test/util/render.js b/test/util/render.js deleted file mode 100644 index f13c6682..00000000 --- a/test/util/render.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Taken from react-youtube's tests at - * https://github.com/troybetz/react-youtube - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -// Doing this after React is loaded makes React do a bit less DOM work -import 'min-react-env/install'; -import env from 'min-react-env'; -import createYouTube from './createYouTube'; - -Object.assign(global, env); - -const render = (initialProps) => { - const { YouTube, sdkMock, playerMock } = createYouTube(); - - let component; - // Emulate changes to component.props using a container component's state - class Container extends React.Component { - constructor(ytProps) { - super(ytProps); - - this.state = { props: ytProps }; - } - - render() { - const { props } = this.state; - - return ( - { component = youtube; }} - {...props} - /> - ); - } - } - - const div = env.document.createElement('div'); - const container = new Promise((resolve) => { - // eslint-disable-next-line react/no-deprecated - ReactDOM.render(, div); - }); - - function rerender(newProps) { - return container.then((wrapper) => new Promise((resolve) => { - wrapper.setState({ props: newProps }, () => { - Promise.resolve().then(resolve); - }); - })); - } - - function unmount() { - // eslint-disable-next-line react/no-deprecated - ReactDOM.unmountComponentAtNode(div); - } - - return component.player.then(() => ({ - sdkMock, - playerMock, - component, - rerender, - unmount, - })); -}; - -export default render; diff --git a/test/util/render.jsx b/test/util/render.jsx new file mode 100644 index 00000000..9a86f0a5 --- /dev/null +++ b/test/util/render.jsx @@ -0,0 +1,92 @@ +/** + * Taken from react-youtube's tests at + * https://github.com/troybetz/react-youtube + */ + +/* global document */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import 'min-react-env/install'; +import createYouTube from './createYouTube'; + +const reactMajor = parseInt((ReactDOM.version || '16').split('.')[0], 10); + +async function render(initialProps) { + const { YouTube, sdkMock, playerMock } = createYouTube(); + + let resolveReady; + const readyPromise = new Promise((resolve) => { + resolveReady = resolve; + }); + + // Emulate changes to component.props using a container component's state + class Container extends React.Component { + constructor(ytProps) { + super(ytProps); + + this.state = { props: ytProps }; + } + + render() { + const { props } = this.state; + + const onReady = (event) => { + resolveReady(); + props.onReady?.(event); + }; + + return ( + + ); + } + } + + const div = document.createElement('div'); + let root; + if (reactMajor >= 18) { + const { createRoot } = await import('react-dom/client'); + root = createRoot(div); + } else { + root = { + render(element) { + // eslint-disable-next-line react/no-deprecated + ReactDOM.render(element, div); + }, + unmount() { + // eslint-disable-next-line react/no-deprecated + ReactDOM.unmountComponentAtNode(div); + }, + }; + } + + const container = new Promise((resolve) => { + act(() => { + root.render(); + }); + }); + await readyPromise; + + async function rerender(newProps) { + const wrapper = await container; + + act(() => { + wrapper.setState({ props: newProps }); + }); + } + + return { + sdkMock, + playerMock, + rerender, + unmount() { + root.unmount(); + }, + }; +} + +export default render; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..b8bfeb6b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "lib": ["dom", "es2020"], + "jsx": "react-jsx", + "outDir": "dist", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/*.tsx", "src/*.ts"] +}