├── .github ├── CODEOWNERS └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── test ├── index.html └── test.js └── youtube-video-element.js /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @muxinc/open-elements 2 | /CODEOWNERS @muxinc/platform-engineering 3 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | concurrency: production 4 | 5 | on: 6 | # Allows you to run this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | inputs: 9 | version: 10 | type: choice 11 | required: true 12 | description: Version 13 | options: 14 | - conventional 15 | - patch 16 | - minor 17 | - major 18 | - prerelease 19 | - from-package 20 | - from-git 21 | prerelease: 22 | type: choice 23 | description: Pre-release 24 | options: 25 | - 26 | - canary 27 | - beta 28 | dryrun: 29 | description: 'Dry-run' 30 | type: boolean 31 | 32 | run-name: Deploy ${{ inputs.version }} ${{ inputs.dryrun && '--dry-run' || '' }} ${{ inputs.prerelease && format('--prerelease {0}', inputs.prerelease) || '' }} 33 | 34 | jobs: 35 | deploy: 36 | runs-on: ubuntu-latest 37 | environment: production 38 | permissions: 39 | contents: write 40 | id-token: write 41 | 42 | env: 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | CONVENTIONAL_GITHUB_RELEASER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | with: 49 | fetch-depth: 0 # Fetch all history for all tags and branches 50 | - uses: actions/setup-node@v4 51 | with: 52 | node-version: 20 53 | # this line is required for the setup-node action to be able to run the npm publish below. 54 | registry-url: 'https://registry.npmjs.org' 55 | - uses: fregante/setup-git-user@v1 56 | - run: npm ci 57 | - run: npm run lint 58 | - run: npm test 59 | - run: npx --yes wet-run@1.2.2 release ${{ inputs.version }} ${{ inputs.dryrun && '--dry-run' || '' }} ${{ inputs.prerelease && format('--prerelease {0}', inputs.prerelease) || '' }} --provenance --changelog --github-release --log-level verbose 60 | - name: Get NPM version 61 | id: npm-version 62 | uses: martinbeentjes/npm-get-version-action@v1.3.1 63 | - name: Released ${{ steps.npm-version.outputs.current-version}} ✨ 64 | run: echo ${{ steps.npm-version.outputs.current-version}} 65 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | - run: npm install 12 | - run: npm run lint 13 | 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | - run: npm install 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | 80 | # Next.js build output 81 | .next 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.1](https://github.com/muxinc/youtube-video-element/compare/v1.0.0...v1.0.1) (2024-04-21) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * upgrade dev deps & fix readme badges ([#27](https://github.com/muxinc/youtube-video-element/issues/27)) ([831e8d7](https://github.com/muxinc/youtube-video-element/commit/831e8d79546e97764b8373c0f70ae3b053ddcaf9)) 7 | 8 | 9 | 10 | # [1.0.0](https://github.com/muxinc/youtube-video-element/compare/v0.2.2...v1.0.0) (2023-09-15) 11 | 12 | 13 | 14 | ## [0.2.2](https://github.com/muxinc/youtube-video-element/compare/v0.2.1...v0.2.2) (2023-09-15) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * playback state on seeking fix [#23](https://github.com/muxinc/youtube-video-element/issues/23) ([b5e60b4](https://github.com/muxinc/youtube-video-element/commit/b5e60b41fe53a40097e149966028e158ff59499f)) 20 | 21 | 22 | ### Reverts 23 | 24 | * Revert "fix: set current time bug fix #23" ([e614165](https://github.com/muxinc/youtube-video-element/commit/e614165999255d8af4f34e1c29b22a67e70259fc)), closes [#23](https://github.com/muxinc/youtube-video-element/issues/23) 25 | 26 | 27 | 28 | ## [0.2.1](https://github.com/muxinc/youtube-video-element/compare/v0.2.0...v0.2.1) (2023-09-15) 29 | 30 | 31 | ### Bug Fixes 32 | 33 | * Next.js SSR document type error ([00b7e23](https://github.com/muxinc/youtube-video-element/commit/00b7e23420e015b038244b7da6a1dc498fa459a0)) 34 | * set current time bug fix [#23](https://github.com/muxinc/youtube-video-element/issues/23) ([92f7de8](https://github.com/muxinc/youtube-video-element/commit/92f7de85003deabb5ac52aa1e583719d2d179094)) 35 | * simplify cd script ([970a978](https://github.com/muxinc/youtube-video-element/commit/970a97829313b16cf173a07c97138acc9cd34eb2)) 36 | 37 | 38 | 39 | # [0.2.0](https://github.com/muxinc/youtube-video-element/compare/v0.1.1...v0.2.0) (2023-01-24) 40 | 41 | 42 | ### Bug Fixes 43 | 44 | * collapsing media element ([6d03ab6](https://github.com/muxinc/youtube-video-element/commit/6d03ab6e0b89b425f08f326627f958ed0961b28c)) 45 | 46 | 47 | 48 | ## [0.1.1](https://github.com/muxinc/youtube-video-element/compare/v0.1.0...v0.1.1) (2023-01-19) 49 | 50 | 51 | ### Bug Fixes 52 | 53 | * add support for YT shorts ([#22](https://github.com/muxinc/youtube-video-element/issues/22)) ([ad1dc84](https://github.com/muxinc/youtube-video-element/commit/ad1dc84d234a7affda57708c60d3def6a050954e)) 54 | 55 | 56 | 57 | # [0.1.0](https://github.com/muxinc/youtube-video-element/compare/v0.0.6...v0.1.0) (2023-01-12) 58 | 59 | 60 | ### Features 61 | 62 | * add refactor + tests ([#19](https://github.com/muxinc/youtube-video-element/issues/19)) ([61fd549](https://github.com/muxinc/youtube-video-element/commit/61fd549bbcda7100d26319db8c4a294213309aac)) 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mux 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This repository moved to [muxinc/media-elements/tree/main/packages/youtube-video-element](https://github.com/muxinc/media-elements/tree/main/packages/youtube-video-element) 3 | > Open issues at [muxinc/media-elements/issues](https://github.com/muxinc/media-elements/issues) 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <youtube-video> 5 | 6 | 10 | 22 | 23 | 24 | 25 | 26 |

<youtube-video>

27 |
28 | 29 | 30 | 31 |
32 | 33 | 34 | 35 |
36 | 37 |

With Media Chrome

38 | 39 | 40 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
60 |
61 | 62 | 117 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-video-element", 3 | "version": "1.0.1", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "youtube-video-element", 9 | "version": "1.0.1", 10 | "license": "MIT", 11 | "devDependencies": { 12 | "wet-run": "^1.2.2" 13 | } 14 | }, 15 | "node_modules/@hono/node-server": { 16 | "version": "1.11.0", 17 | "dev": true, 18 | "license": "MIT", 19 | "engines": { 20 | "node": ">=18.14.1" 21 | } 22 | }, 23 | "node_modules/hono": { 24 | "version": "4.2.5", 25 | "dev": true, 26 | "license": "MIT", 27 | "engines": { 28 | "node": ">=16.0.0" 29 | } 30 | }, 31 | "node_modules/playwright-core": { 32 | "version": "1.43.1", 33 | "dev": true, 34 | "license": "Apache-2.0", 35 | "bin": { 36 | "playwright-core": "cli.js" 37 | }, 38 | "engines": { 39 | "node": ">=16" 40 | } 41 | }, 42 | "node_modules/wet-run": { 43 | "version": "1.2.2", 44 | "dev": true, 45 | "license": "MIT", 46 | "dependencies": { 47 | "@hono/node-server": "^1.11.0", 48 | "hono": "^4.2.5", 49 | "playwright-core": "^1.43.1", 50 | "ws": "^8.16.0" 51 | }, 52 | "bin": { 53 | "wet": "src/cli.js" 54 | }, 55 | "engines": { 56 | "node": ">=18.3.0" 57 | } 58 | }, 59 | "node_modules/ws": { 60 | "version": "8.16.0", 61 | "dev": true, 62 | "license": "MIT", 63 | "engines": { 64 | "node": ">=10.0.0" 65 | }, 66 | "peerDependencies": { 67 | "bufferutil": "^4.0.1", 68 | "utf-8-validate": ">=5.0.2" 69 | }, 70 | "peerDependenciesMeta": { 71 | "bufferutil": { 72 | "optional": true 73 | }, 74 | "utf-8-validate": { 75 | "optional": true 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-video-element", 3 | "version": "1.0.1", 4 | "description": "Custom element (web component) for the YouTube player.", 5 | "type": "module", 6 | "main": "youtube-video-element.js", 7 | "files": [], 8 | "scripts": { 9 | "lint": "npx eslint@8 *.js -c ./node_modules/wet-run/.eslintrc.json", 10 | "test": "wet run", 11 | "dev": "wet serve" 12 | }, 13 | "repository": "muxinc/youtube-video-element", 14 | "author": "@muxinc", 15 | "license": "MIT", 16 | "homepage": "https://github.com/muxinc/youtube-video-element#readme", 17 | "bugs": { 18 | "url": "https://github.com/muxinc/youtube-video-element/issues" 19 | }, 20 | "devDependencies": { 21 | "wet-run": "^1.2.2" 22 | }, 23 | "keywords": [ 24 | "youtube", 25 | "video", 26 | "player", 27 | "web component", 28 | "custom element" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | 3 | function createVideoElement() { 4 | return fixture(``); 8 | } 9 | 10 | test('has default video props', async function (t) { 11 | const video = await createVideoElement(); 12 | 13 | t.equal(video.paused, true, 'is paused on initialization'); 14 | 15 | await video.loadComplete; 16 | 17 | t.equal(video.paused, true, 'is paused on initialization'); 18 | t.ok(!video.ended, 'is not ended'); 19 | t.ok(video.muted, 'is muted'); 20 | }); 21 | 22 | test('seeking while paused stays paused', async function (t) { 23 | const video = await createVideoElement(); 24 | 25 | t.equal(video.paused, true, 'is paused on initialization'); 26 | 27 | await video.loadComplete; 28 | 29 | video.currentTime = 23; 30 | await promisify(video.addEventListener.bind(video))('seeked'); 31 | 32 | await delay(300); // postMessage is not instant 33 | t.equal(video.paused, true, 'is paused after seek'); 34 | t.equal(Math.floor(video.currentTime), 23); 35 | }); 36 | 37 | test('seeking while playing stays playing', async function (t) { 38 | const video = await createVideoElement(); 39 | 40 | t.equal(video.paused, true, 'is paused on initialization'); 41 | 42 | await video.loadComplete; 43 | 44 | try { 45 | await video.play(); 46 | } catch (error) { 47 | console.warn(error); 48 | } 49 | t.ok(!video.paused, 'is playing after video.play()'); 50 | 51 | video.currentTime = 23; 52 | await promisify(video.addEventListener.bind(video))('seeked'); 53 | 54 | await delay(300); // postMessage is not instant 55 | t.ok(!video.paused, 'is playing after seek'); 56 | t.equal(Math.floor(video.currentTime), 23); 57 | }); 58 | 59 | test('volume', async function (t) { 60 | const video = await createVideoElement(); 61 | await video.loadComplete; 62 | 63 | video.volume = 1; 64 | await delay(100); // postMessage is not instant 65 | t.equal(video.volume, 1, 'is all turned up. volume: ' + video.volume); 66 | video.volume = 0.5; 67 | await delay(700); // postMessage is not instant 68 | t.equal(video.volume, 0.5, 'is half volume'); 69 | }); 70 | 71 | test('loop', async function (t) { 72 | const video = await createVideoElement(); 73 | await video.loadComplete; 74 | 75 | t.ok(!video.loop, 'loop is false by default'); 76 | video.loop = true; 77 | t.ok(video.loop, 'loop is true'); 78 | }); 79 | 80 | test('duration', async function (t) { 81 | const video = await createVideoElement(); 82 | await video.loadComplete; 83 | 84 | if (video.duration == null || Number.isNaN(video.duration)) { 85 | await promisify(video.addEventListener.bind(video))('durationchange'); 86 | } 87 | 88 | t.equal(Math.round(video.duration), 254, `is 254s long`); 89 | }); 90 | 91 | test('load promise', async function (t) { 92 | const video = await createVideoElement(); 93 | await video.loadComplete; 94 | 95 | const loadComplete = video.loadComplete; 96 | 97 | video.src = 'https://www.youtube.com/watch?v=C7dPqrmDWxs'; 98 | await video.loadComplete; 99 | 100 | t.ok( 101 | loadComplete != video.loadComplete, 102 | 'creates a new promise after new src' 103 | ); 104 | 105 | if (video.duration == null || Number.isNaN(video.duration)) { 106 | await promisify(video.addEventListener.bind(video))('durationchange'); 107 | } 108 | 109 | t.equal(Math.round(video.duration), 235, `is 235s long`); 110 | }); 111 | 112 | test('play promise', async function (t) { 113 | const video = await createVideoElement(); 114 | await video.loadComplete; 115 | 116 | video.muted = true; 117 | 118 | try { 119 | await video.play(); 120 | } catch (error) { 121 | console.warn(error); 122 | } 123 | t.ok(!video.paused, 'is playing after video.play()'); 124 | }); 125 | 126 | function delay(ms) { 127 | return new Promise((resolve) => setTimeout(resolve, ms)); 128 | } 129 | 130 | async function fixture(html) { 131 | const template = document.createElement('template'); 132 | template.innerHTML = html; 133 | const fragment = template.content.cloneNode(true); 134 | const result = fragment.children.length > 1 135 | ? [...fragment.children] 136 | : fragment.children[0]; 137 | document.body.append(fragment); 138 | return result; 139 | } 140 | 141 | function promisify(fn) { 142 | return (...args) => 143 | new Promise((resolve) => { 144 | fn(...args, (...res) => { 145 | if (res.length > 1) resolve(res); 146 | else resolve(res[0]); 147 | }); 148 | }); 149 | } 150 | -------------------------------------------------------------------------------- /youtube-video-element.js: -------------------------------------------------------------------------------- 1 | // https://developers.google.com/youtube/iframe_api_reference 2 | 3 | const EMBED_BASE = 'https://www.youtube.com/embed'; 4 | const API_URL = 'https://www.youtube.com/iframe_api'; 5 | const API_GLOBAL = 'YT'; 6 | const API_GLOBAL_READY = 'onYouTubeIframeAPIReady'; 7 | const MATCH_SRC = 8 | /(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/; 9 | 10 | const templateShadowDOM = globalThis.document?.createElement('template'); 11 | if (templateShadowDOM) { 12 | templateShadowDOM.innerHTML = /*html*/` 13 | 27 | `; 28 | } 29 | 30 | class YoutubeVideoElement extends (globalThis.HTMLElement ?? class {}) { 31 | static observedAttributes = [ 32 | 'autoplay', 33 | 'controls', 34 | 'crossorigin', 35 | 'loop', 36 | 'muted', 37 | 'playsinline', 38 | 'poster', 39 | 'preload', 40 | 'src', 41 | ]; 42 | 43 | #options; 44 | #readyState = 0; 45 | #seeking = false; 46 | #seekComplete; 47 | isLoaded = false; 48 | 49 | constructor() { 50 | super(); 51 | 52 | this.attachShadow({ mode: 'open' }); 53 | this.shadowRoot.append(templateShadowDOM.content.cloneNode(true)); 54 | 55 | this.loadComplete = new PublicPromise(); 56 | } 57 | 58 | async load() { 59 | if (this.hasLoaded) { 60 | this.loadComplete = new PublicPromise(); 61 | this.isLoaded = false; 62 | } 63 | this.hasLoaded = true; 64 | 65 | this.#readyState = 0; 66 | this.dispatchEvent(new Event('emptied')); 67 | 68 | let oldApi = this.api; 69 | this.api = null; 70 | 71 | // Wait 1 tick to allow other attributes to be set. 72 | await Promise.resolve(); 73 | 74 | oldApi?.destroy(); 75 | 76 | if (!this.src) { 77 | return; 78 | } 79 | 80 | this.dispatchEvent(new Event('loadstart')); 81 | 82 | this.#options = { 83 | autoplay: this.autoplay, 84 | controls: this.controls, 85 | loop: this.loop, 86 | mute: this.defaultMuted, 87 | playsinline: this.playsInline, 88 | preload: this.preload ?? 'metadata', 89 | origin: location.origin, 90 | enablejsapi: 1, 91 | showinfo: 0, 92 | rel: 0, 93 | iv_load_policy: 3, 94 | modestbranding: 1, 95 | }; 96 | 97 | const matches = this.src.match(MATCH_SRC); 98 | const metaId = matches && matches[1]; 99 | const src = `${EMBED_BASE}/${metaId}?${serialize( 100 | boolToBinary(this.#options) 101 | )}`; 102 | let iframe = this.shadowRoot.querySelector('iframe'); 103 | if (!iframe) { 104 | iframe = createEmbedIframe({ src }); 105 | this.shadowRoot.append(iframe); 106 | } 107 | 108 | const YT = await loadScript(API_URL, API_GLOBAL, API_GLOBAL_READY); 109 | this.api = new YT.Player(iframe, { 110 | events: { 111 | onReady: () => { 112 | this.#readyState = 1; // HTMLMediaElement.HAVE_METADATA 113 | this.dispatchEvent(new Event('loadedmetadata')); 114 | this.dispatchEvent(new Event('durationchange')); 115 | this.dispatchEvent(new Event('volumechange')); 116 | this.dispatchEvent(new Event('loadcomplete')); 117 | this.isLoaded = true; 118 | this.loadComplete.resolve(); 119 | }, 120 | onError: (error) => console.error(error), 121 | }, 122 | }); 123 | 124 | /* onStateChange 125 | -1 (unstarted) 126 | 0 (ended) 127 | 1 (playing) 128 | 2 (paused) 129 | 3 (buffering) 130 | 5 (video cued). 131 | */ 132 | 133 | let playFired = false; 134 | this.api.addEventListener('onStateChange', (event) => { 135 | const state = event.data; 136 | if ( 137 | state === YT.PlayerState.PLAYING || 138 | state === YT.PlayerState.BUFFERING 139 | ) { 140 | if (!playFired) { 141 | playFired = true; 142 | this.dispatchEvent(new Event('play')); 143 | } 144 | } 145 | 146 | if (state === YT.PlayerState.PLAYING) { 147 | if (this.seeking) { 148 | this.#seeking = false; 149 | this.#seekComplete?.resolve(); 150 | this.dispatchEvent(new Event('seeked')); 151 | } 152 | this.#readyState = 3; // HTMLMediaElement.HAVE_FUTURE_DATA 153 | this.dispatchEvent(new Event('playing')); 154 | } else if (state === YT.PlayerState.PAUSED) { 155 | const diff = Math.abs(this.currentTime - lastCurrentTime); 156 | if (!this.seeking && diff > 0.1) { 157 | this.#seeking = true; 158 | this.dispatchEvent(new Event('seeking')); 159 | } 160 | playFired = false; 161 | this.dispatchEvent(new Event('pause')); 162 | } 163 | if (state === YT.PlayerState.ENDED) { 164 | playFired = false; 165 | this.dispatchEvent(new Event('pause')); 166 | this.dispatchEvent(new Event('ended')); 167 | 168 | if (this.loop) { 169 | this.play(); 170 | } 171 | } 172 | }); 173 | 174 | this.api.addEventListener('onPlaybackRateChange', () => { 175 | this.dispatchEvent(new Event('ratechange')); 176 | }); 177 | 178 | this.api.addEventListener('onVolumeChange', () => { 179 | this.dispatchEvent(new Event('volumechange')); 180 | }); 181 | 182 | this.api.addEventListener('onVideoProgress', () => { 183 | this.dispatchEvent(new Event('timeupdate')); 184 | }); 185 | 186 | await this.loadComplete; 187 | 188 | let lastCurrentTime = 0; 189 | setInterval(() => { 190 | const diff = Math.abs(this.currentTime - lastCurrentTime); 191 | const bufferedEnd = this.buffered.end(this.buffered.length - 1); 192 | if (this.seeking && bufferedEnd > 0.1) { 193 | this.#seeking = false; 194 | this.#seekComplete?.resolve(); 195 | this.dispatchEvent(new Event('seeked')); 196 | } else if (!this.seeking && diff > 0.1) { 197 | this.#seeking = true; 198 | this.dispatchEvent(new Event('seeking')); 199 | } 200 | lastCurrentTime = this.currentTime; 201 | }, 50); 202 | 203 | let lastBufferedEnd; 204 | const progressInterval = setInterval(() => { 205 | const bufferedEnd = this.buffered.end(this.buffered.length - 1); 206 | if (bufferedEnd >= this.duration) { 207 | clearInterval(progressInterval); 208 | this.#readyState = 4; // HTMLMediaElement.HAVE_ENOUGH_DATA 209 | } 210 | if (lastBufferedEnd != bufferedEnd) { 211 | lastBufferedEnd = bufferedEnd; 212 | this.dispatchEvent(new Event('progress')); 213 | } 214 | }, 100); 215 | } 216 | 217 | async attributeChangedCallback(attrName) { 218 | // This is required to come before the await for resolving loadComplete. 219 | switch (attrName) { 220 | case 'src': { 221 | this.load(); 222 | return; 223 | } 224 | } 225 | 226 | await this.loadComplete; 227 | 228 | switch (attrName) { 229 | case 'autoplay': 230 | case 'controls': 231 | case 'loop': 232 | case 'playsinline': { 233 | if (this.#options[attrName] !== this.hasAttribute(attrName)) { 234 | this.load(); 235 | } 236 | break; 237 | } 238 | } 239 | } 240 | 241 | async play() { 242 | this.#seekComplete = null; 243 | await this.loadComplete; 244 | // yt.playVideo doesn't return a play promise. 245 | this.api?.playVideo(); 246 | return createPlayPromise(this); 247 | } 248 | 249 | async pause() { 250 | await this.loadComplete; 251 | return this.api?.pauseVideo(); 252 | } 253 | 254 | get seeking() { 255 | return this.#seeking; 256 | } 257 | 258 | get readyState() { 259 | return this.#readyState; 260 | } 261 | 262 | // If the getter from SuperVideoElement is overridden, it's required to define 263 | // the setter again too unless it's a read only property! It's a JS thing. 264 | 265 | get src() { 266 | return this.getAttribute('src'); 267 | } 268 | 269 | set src(val) { 270 | if (this.src == val) return; 271 | this.setAttribute('src', val); 272 | } 273 | 274 | /* onStateChange 275 | -1 (unstarted) 276 | 0 (ended) 277 | 1 (playing) 278 | 2 (paused) 279 | 3 (buffering) 280 | 5 (video cued). 281 | */ 282 | 283 | get paused() { 284 | if (!this.isLoaded) return !this.autoplay; 285 | return [-1, 0, 2, 5].includes(this.api?.getPlayerState?.()); 286 | } 287 | 288 | get duration() { 289 | return this.api?.getDuration?.() ?? NaN; 290 | } 291 | 292 | get autoplay() { 293 | return this.hasAttribute('autoplay'); 294 | } 295 | 296 | set autoplay(val) { 297 | if (this.autoplay == val) return; 298 | if (val) this.setAttribute('autoplay', ''); 299 | else this.removeAttribute('autoplay'); 300 | } 301 | 302 | get buffered() { 303 | if (!this.isLoaded) return createTimeRanges(); 304 | const progress = 305 | this.api?.getVideoLoadedFraction() * this.api?.getDuration(); 306 | if (progress > 0) { 307 | return createTimeRanges(0, progress); 308 | } 309 | return createTimeRanges(); 310 | } 311 | 312 | get controls() { 313 | return this.hasAttribute('controls'); 314 | } 315 | 316 | set controls(val) { 317 | if (this.controls == val) return; 318 | if (val) this.setAttribute('controls', ''); 319 | else this.removeAttribute('controls'); 320 | } 321 | 322 | get currentTime() { 323 | return this.api?.getCurrentTime?.() ?? 0; 324 | } 325 | 326 | set currentTime(val) { 327 | if (this.currentTime == val) return; 328 | this.#seekComplete = new PublicPromise(); 329 | this.loadComplete.then(() => { 330 | this.api?.seekTo(val, true); 331 | if (this.paused) { 332 | this.#seekComplete?.then(() => { 333 | if (!this.#seekComplete) return; 334 | this.api?.pauseVideo(); 335 | }); 336 | } 337 | }); 338 | } 339 | 340 | set defaultMuted(val) { 341 | if (this.defaultMuted == val) return; 342 | if (val) this.setAttribute('muted', ''); 343 | else this.removeAttribute('muted'); 344 | } 345 | 346 | get defaultMuted() { 347 | return this.hasAttribute('muted'); 348 | } 349 | 350 | get loop() { 351 | return this.hasAttribute('loop'); 352 | } 353 | 354 | set loop(val) { 355 | if (this.loop == val) return; 356 | if (val) this.setAttribute('loop', ''); 357 | else this.removeAttribute('loop'); 358 | } 359 | 360 | set muted(val) { 361 | if (this.muted == val) return; 362 | this.loadComplete.then(() => { 363 | val ? this.api?.mute() : this.api?.unMute(); 364 | }); 365 | } 366 | 367 | get muted() { 368 | if (!this.isLoaded) return this.defaultMuted; 369 | return this.api?.isMuted?.(); 370 | } 371 | 372 | get playbackRate() { 373 | return this.api?.getPlaybackRate?.() ?? 1; 374 | } 375 | 376 | set playbackRate(val) { 377 | if (this.playbackRate == val) return; 378 | this.loadComplete.then(() => { 379 | this.api?.setPlaybackRate(val); 380 | }); 381 | } 382 | 383 | get playsInline() { 384 | return this.hasAttribute('playsinline'); 385 | } 386 | 387 | set playsInline(val) { 388 | if (this.playsInline == val) return; 389 | if (val) this.setAttribute('playsinline', ''); 390 | else this.removeAttribute('playsinline'); 391 | } 392 | 393 | get poster() { 394 | return this.getAttribute('poster'); 395 | } 396 | 397 | set poster(val) { 398 | if (this.poster == val) return; 399 | this.setAttribute('poster', `${val}`); 400 | } 401 | 402 | set volume(val) { 403 | if (this.volume == val) return; 404 | this.loadComplete.then(() => { 405 | this.api?.setVolume(val * 100); 406 | }); 407 | } 408 | 409 | get volume() { 410 | if (!this.isLoaded) return 1; 411 | return this.api?.getVolume() / 100; 412 | } 413 | } 414 | 415 | const loadScriptCache = {}; 416 | async function loadScript(src, globalName, readyFnName) { 417 | if (loadScriptCache[src]) return loadScriptCache[src]; 418 | if (globalName && self[globalName]) { 419 | await delay(0); 420 | return self[globalName]; 421 | } 422 | return (loadScriptCache[src] = new Promise(function (resolve, reject) { 423 | const script = document.createElement('script'); 424 | script.src = src; 425 | const ready = () => resolve(self[globalName]); 426 | if (readyFnName) (self[readyFnName] = ready); 427 | script.onload = () => !readyFnName && ready(); 428 | script.onerror = reject; 429 | document.head.append(script); 430 | })); 431 | } 432 | 433 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); 434 | 435 | function promisify(fn) { 436 | return (...args) => 437 | new Promise((resolve) => { 438 | fn(...args, (...res) => { 439 | if (res.length > 1) resolve(res); 440 | else resolve(res[0]); 441 | }); 442 | }); 443 | } 444 | 445 | function createPlayPromise(player) { 446 | return promisify((event, cb) => { 447 | let fn; 448 | player.addEventListener( 449 | event, 450 | (fn = () => { 451 | player.removeEventListener(event, fn); 452 | cb(); 453 | }) 454 | ); 455 | })('playing'); 456 | } 457 | 458 | /** 459 | * A utility to create Promises with convenient public resolve and reject methods. 460 | * @return {Promise} 461 | */ 462 | class PublicPromise extends Promise { 463 | constructor(executor = () => {}) { 464 | let res, rej; 465 | super((resolve, reject) => { 466 | executor(resolve, reject); 467 | res = resolve; 468 | rej = reject; 469 | }); 470 | this.resolve = res; 471 | this.reject = rej; 472 | } 473 | } 474 | 475 | function createElement(tag, attrs = {}, ...children) { 476 | const el = document.createElement(tag); 477 | Object.keys(attrs).forEach( 478 | (name) => attrs[name] != null && el.setAttribute(name, attrs[name]) 479 | ); 480 | el.append(...children); 481 | return el; 482 | } 483 | 484 | const allow = 485 | 'accelerometer; autoplay; fullscreen; encrypted-media; gyroscope; picture-in-picture'; 486 | 487 | function createEmbedIframe({ src, ...props }) { 488 | return createElement('iframe', { 489 | src, 490 | width: '100%', 491 | height: '100%', 492 | allow, 493 | frameborder: 0, 494 | ...props, 495 | }); 496 | } 497 | 498 | function serialize(props) { 499 | return Object.keys(props) 500 | .map((key) => { 501 | if (props[key] == null) return ''; 502 | return `${key}=${encodeURIComponent(props[key])}`; 503 | }) 504 | .join('&'); 505 | } 506 | 507 | function boolToBinary(props) { 508 | let p = { ...props }; 509 | for (let key in p) { 510 | if (p[key] === false) p[key] = 0; 511 | else if (p[key] === true) p[key] = 1; 512 | } 513 | return p; 514 | } 515 | 516 | /** 517 | * Creates a fake `TimeRanges` object. 518 | * 519 | * A TimeRanges object. This object is normalized, which means that ranges are 520 | * ordered, don't overlap, aren't empty, and don't touch (adjacent ranges are 521 | * folded into one bigger range). 522 | * 523 | * @param {(Number|Array)} Start of a single range or an array of ranges 524 | * @param {Number} End of a single range 525 | * @return {Array} 526 | */ 527 | function createTimeRanges(start, end) { 528 | if (Array.isArray(start)) { 529 | return createTimeRangesObj(start); 530 | } else if (start == null || end == null || (start === 0 && end === 0)) { 531 | return createTimeRangesObj([[0, 0]]); 532 | } 533 | return createTimeRangesObj([[start, end]]); 534 | } 535 | 536 | function createTimeRangesObj(ranges) { 537 | Object.defineProperties(ranges, { 538 | start: { 539 | value: i => ranges[i][0] 540 | }, 541 | end: { 542 | value: i => ranges[i][1] 543 | } 544 | }); 545 | return ranges; 546 | } 547 | 548 | if (globalThis.customElements && !globalThis.customElements.get('youtube-video')) { 549 | globalThis.customElements.define('youtube-video', YoutubeVideoElement); 550 | } 551 | 552 | export default YoutubeVideoElement; 553 | --------------------------------------------------------------------------------