├── .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 |
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 |
--------------------------------------------------------------------------------