├── .editorconfig
├── .gitignore
├── .travis.yml
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── eslint.config.js
├── package.json
├── postcss.config.js
├── rollup.config.js
├── src
├── index.css
└── index.js
└── stylelint.config.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | end_of_line = lf
6 | indent_style = tab
7 | insert_final_newline = true
8 | trim_trailing_whitespace = true
9 |
10 | [*.md]
11 | trim_trailing_whitespace = false
12 |
13 | [*.{json,md,yml}]
14 | indent_size = 2
15 | indent_style = space
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package-lock.json
3 | *.log*
4 | .*
5 | !.editorconfig
6 | !.gitignore
7 | !.rollup.*
8 | !.travis.yml
9 | /browser.*
10 | /index.*
11 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | # https://docs.travis-ci.com/user/travis-lint
2 |
3 | language: node_js
4 |
5 | node_js:
6 | - 6
7 |
8 | install:
9 | - npm install --ignore-scripts
10 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changes to Media Player
2 |
3 | ### 2.0.1 (Apr 1, 2019)
4 |
5 | - Fixed issue with keyboard controls
6 |
7 | ### 2.0.0 (Aug 2, 2018)
8 |
9 | - Removed support for Node 4.x
10 | - Fixed misnamed property (`downloadLink`, now `downloadSymbol`)
11 | - Changed browserslist to `.browserslistrc` to compile distributed CSS and JS
12 | - Updated documentation
13 |
14 | ### 1.1.0 (Aug 31, 2017)
15 |
16 | - Updated default ordering of elements
17 | - Updated documentation
18 |
19 | ### 1.0.0 (Aug 30, 2017)
20 |
21 | - Added ability to configure time and volume directions
22 | - Added compiled CSS to package
23 | - Added undocumented "private" Fullscreen API helper properties on the instance
24 | - Removed `show` option and visible/hidden classes
25 | - Updated LTR-preserved controls in LTR environment while allowing overrides
26 | - Updated and simplified instance properties
27 | - Updated and improved drag functionality
28 | - Updated and improved ARIA attributes for player, media, and toolbar
29 | - Updated and improved how download button works
30 | - Updated and improved cross-browser fullscreen functionality
31 | - Updated how keydown event properties are extracted
32 | - Updated how document is detected
33 | - Updated media to be un-tabbable
34 | - Updated DOM to be on instance rather than instance `dom` sub-property
35 | - Updated styles
36 | - Updated demos
37 |
38 | ### 0.1.0 (Aug 24, 2017)
39 |
40 | - Initial version
41 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Media Player
2 |
3 | You want to help? You rock! But please, take a moment to be sure your
4 | contributions make sense to everyone else.
5 |
6 | ## Reporting Issues
7 |
8 | Found a problem? Want a new feature?
9 |
10 | - Please, see if your issue or idea has [already been reported].
11 | - Please, provide a [reduced test case] or a [live example].
12 |
13 | Remember, a bug is a _demonstrable problem_ caused by _our_ code.
14 |
15 | ## Submitting Pull Requests
16 |
17 | Pull requests are the greatest contributions, so be sure they are focused in
18 | scope and avoid unrelated commits.
19 |
20 | 1. To begin; [fork this project] and then clone your fork locally
21 | ```bash
22 | # Clone your fork of this project
23 | git clone git@github.com:YOUR_USER/media-player.git
24 |
25 | # Navigate to your fork of this project
26 | cd media-player
27 |
28 | # Install the tools necessary for testing this project
29 | npm install
30 |
31 | # Assign the original repo to a remote called "upstream"
32 | git remote add upstream git@github.com:jonathantneal/media-player.git
33 |
34 | # Clone your fork of the gh-pages branch / directory
35 | git clone --branch gh-pages git@github.com:YOUR_USER/media-player.git gh-pages
36 |
37 | # Navigate to your fork of the gh-pages branch
38 | cd gh-pages
39 |
40 | # Assign the original branch to a remote called "upstream"
41 | git remote add upstream git@github.com:jonathantneal/media-player.git
42 | ```
43 |
44 | 2. Create a branch for your feature or fix:
45 | ```bash
46 | # Move into a new branch for your feature
47 | git checkout -b feature/thing
48 | ```
49 | ```bash
50 | # Move into a new branch for your fix
51 | git checkout -b fix/something
52 | ```
53 |
54 | 3. If your code follows our practices, then push your feature branch:
55 | ```bash
56 | # Test current code
57 | npm test
58 | ```
59 | ```bash
60 | # Push the branch for your new feature
61 | git push origin feature/thing
62 | ```
63 | ```bash
64 | # Or, push the branch for your update
65 | git push origin update/something
66 | ```
67 |
68 | That’s it! Now [open a pull request] with a clear title and description.
69 |
70 | [already been reported]: https://github.com/jonathantneal/media-player/issues
71 | [fork this project]: https://github.com/jonathantneal/media-player/fork
72 | [live example]: https://codepen.io/pen
73 | [open a pull request]: https://help.github.com/articles/using-pull-requests/
74 | [reduced test case]: https://css-tricks.com/reduced-test-cases/
75 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # CC0 1.0 Universal
2 |
3 | ## Statement of Purpose
4 |
5 | The laws of most jurisdictions throughout the world automatically confer
6 | exclusive Copyright and Related Rights (defined below) upon the creator and
7 | subsequent owner(s) (each and all, an “owner”) of an original work of
8 | authorship and/or a database (each, a “Work”).
9 |
10 | Certain owners wish to permanently relinquish those rights to a Work for the
11 | purpose of contributing to a commons of creative, cultural and scientific works
12 | (“Commons”) that the public can reliably and without fear of later claims of
13 | infringement build upon, modify, incorporate in other works, reuse and
14 | redistribute as freely as possible in any form whatsoever and for any purposes,
15 | including without limitation commercial purposes. These owners may contribute
16 | to the Commons to promote the ideal of a free culture and the further
17 | production of creative, cultural and scientific works, or to gain reputation or
18 | greater distribution for their Work in part through the use and efforts of
19 | others.
20 |
21 | For these and/or other purposes and motivations, and without any expectation of
22 | additional consideration or compensation, the person associating CC0 with a
23 | Work (the “Affirmer”), to the extent that he or she is an owner of Copyright
24 | and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and
25 | publicly distribute the Work under its terms, with knowledge of his or her
26 | Copyright and Related Rights in the Work and the meaning and intended legal
27 | effect of CC0 on those rights.
28 |
29 | 1. Copyright and Related Rights. A Work made available under CC0 may be
30 | protected by copyright and related or neighboring rights (“Copyright and
31 | Related Rights”). Copyright and Related Rights include, but are not limited
32 | to, the following:
33 | 1. the right to reproduce, adapt, distribute, perform, display, communicate,
34 | and translate a Work;
35 | 2. moral rights retained by the original author(s) and/or performer(s);
36 | 3. publicity and privacy rights pertaining to a person’s image or likeness
37 | depicted in a Work;
38 | 4. rights protecting against unfair competition in regards to a Work,
39 | subject to the limitations in paragraph 4(i), below;
40 | 5. rights protecting the extraction, dissemination, use and reuse of data in
41 | a Work;
42 | 6. database rights (such as those arising under Directive 96/9/EC of the
43 | European Parliament and of the Council of 11 March 1996 on the legal
44 | protection of databases, and under any national implementation thereof,
45 | including any amended or successor version of such directive); and
46 | 7. other similar, equivalent or corresponding rights throughout the world
47 | based on applicable law or treaty, and any national implementations
48 | thereof.
49 |
50 | 2. Waiver. To the greatest extent permitted by, but not in contravention of,
51 | applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
52 | unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright
53 | and Related Rights and associated claims and causes of action, whether now
54 | known or unknown (including existing as well as future claims and causes of
55 | action), in the Work (i) in all territories worldwide, (ii) for the maximum
56 | duration provided by applicable law or treaty (including future time
57 | extensions), (iii) in any current or future medium and for any number of
58 | copies, and (iv) for any purpose whatsoever, including without limitation
59 | commercial, advertising or promotional purposes (the “Waiver”). Affirmer
60 | makes the Waiver for the benefit of each member of the public at large and
61 | to the detriment of Affirmer’s heirs and successors, fully intending that
62 | such Waiver shall not be subject to revocation, rescission, cancellation,
63 | termination, or any other legal or equitable action to disrupt the quiet
64 | enjoyment of the Work by the public as contemplated by Affirmer’s express
65 | Statement of Purpose.
66 |
67 | 3. Public License Fallback. Should any part of the Waiver for any reason be
68 | judged legally invalid or ineffective under applicable law, then the Waiver
69 | shall be preserved to the maximum extent permitted taking into account
70 | Affirmer’s express Statement of Purpose. In addition, to the extent the
71 | Waiver is so judged Affirmer hereby grants to each affected person a
72 | royalty-free, non transferable, non sublicensable, non exclusive,
73 | irrevocable and unconditional license to exercise Affirmer’s Copyright and
74 | Related Rights in the Work (i) in all territories worldwide, (ii) for the
75 | maximum duration provided by applicable law or treaty (including future time
76 | extensions), (iii) in any current or future medium and for any number of
77 | copies, and (iv) for any purpose whatsoever, including without limitation
78 | commercial, advertising or promotional purposes (the “License”). The License
79 | shall be deemed effective as of the date CC0 was applied by Affirmer to the
80 | Work. Should any part of the License for any reason be judged legally
81 | invalid or ineffective under applicable law, such partial invalidity or
82 | ineffectiveness shall not invalidate the remainder of the License, and in
83 | such case Affirmer hereby affirms that he or she will not (i) exercise any
84 | of his or her remaining Copyright and Related Rights in the Work or (ii)
85 | assert any associated claims and causes of action with respect to the Work,
86 | in either case contrary to Affirmer’s express Statement of Purpose.
87 |
88 | 4. Limitations and Disclaimers.
89 | 1. No trademark or patent rights held by Affirmer are waived, abandoned,
90 | surrendered, licensed or otherwise affected by this document.
91 | 2. Affirmer offers the Work as-is and makes no representations or warranties
92 | of any kind concerning the Work, express, implied, statutory or
93 | otherwise, including without limitation warranties of title,
94 | merchantability, fitness for a particular purpose, non infringement, or
95 | the absence of latent or other defects, accuracy, or the present or
96 | absence of errors, whether or not discoverable, all to the greatest
97 | extent permissible under applicable law.
98 | 3. Affirmer disclaims responsibility for clearing rights of other persons
99 | that may apply to the Work or any use thereof, including without
100 | limitation any person’s Copyright and Related Rights in the Work.
101 | Further, Affirmer disclaims responsibility for obtaining any necessary
102 | consents, permissions or other rights required for any use of the Work.
103 | 4. Affirmer understands and acknowledges that Creative Commons is not a
104 | party to this document and has no duty or obligation with respect to this
105 | CC0 or use of the Work.
106 |
107 | For more information, please see
108 | http://creativecommons.org/publicdomain/zero/1.0/.
109 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Media Player [][Media Player]
2 |
3 | [![NPM Version][npm-img]][npm-url]
4 | [![NPM Filesize][fsz-img]][fsz-url]
5 | [![Build Status][cli-img]][cli-url]
6 |
7 | [Media Player] is a tiny, responsive, international, accessible, cross browser,
8 | easily customizable media player written in plain vanilla JavaScript.
9 |
10 |
11 |
12 |
13 |
14 | [Media Player] can be controlled with any pointer or keyboard, whether it’s to
15 | play, pause, move across the timeline, mute, unmute, adjust the volume, enter
16 | or leave fullscreen, or download the source.
17 |
18 |
19 |
20 |
21 |
22 | [Media Player] is designed for developers who want complete visual control over
23 | the component. It’s also for developers who want to hack at or extend the
24 | player without any fuss. The player itself does all the heavy lifting; semantic
25 | markup, accessibility management, language, fullscreen, text direction,
26 | providing pointer-agnostic scrubbable timelines, and lots of other cool
27 | sounding stuff.
28 |
29 |
30 |
31 |
32 |
33 | ## Usage
34 |
35 | Add the required JavaScript and default CSS to your page.
36 |
37 | ```html
38 |
39 |
40 | ```
41 |
42 | ### Node Usage
43 |
44 | ```bash
45 | npm install --save mediaplayer
46 | ```
47 |
48 | Import the library and create a new [Media Player] using any media element:
49 |
50 | ```js
51 | import MediaPlayer from 'mediaplayer';
52 |
53 | // get target from media with controls
54 | const $target = document.querySelector('audio[controls], video[controls]');
55 |
56 | // assign media player from target (all these options represent the defaults)
57 | const player = new MediaPlayer(
58 | $target,
59 | {
60 | prefix: 'media',
61 | lang: {
62 | play: 'play',
63 | pause: 'pause',
64 | mute: 'mute',
65 | unmute: 'unmute',
66 | volume: 'volume',
67 | currentTime: 'current time',
68 | remainingTime: 'remaining time',
69 | enterFullscreen: 'enter fullscreen',
70 | leaveFullscreen: 'leave fullscreen',
71 | download: 'download'
72 | },
73 | svgs: {
74 | play: '#symbol-play',
75 | pause: '#symbol-pause',
76 | mute: '#symbol-mute',
77 | unmute: '#symbol-unmute',
78 | volume: '#symbol-volume',
79 | currentTime: '#symbol-currentTime',
80 | remainingTime: '#symbol-remainingTime',
81 | enterFullscreen: '#symbol-enterFullscreen',
82 | leaveFullscreen: '#symbol-leaveFullscreen',
83 | download: '#symbol-download'
84 | },
85 | timeDir: 'ltr',
86 | volumeDir: 'ltr'
87 | }
88 | );
89 | ```
90 |
91 | The `prefix` option is used to prefix flat class names, which are otherwise:
92 |
93 | ```scss
94 | .media-player {}
95 |
96 | .media-media { /* video or audio element */ }
97 |
98 | .media-toolbar {}
99 |
100 | .media-control.media-play {}
101 | .media-symbol.media-play-symbol { /* play svg */ }
102 | .media-symbol.media-pause-symbol { /* pause svg */ }
103 |
104 | .media-control.media-mute {}
105 | .media-symbol.media-mute-symbol { /* mute svg */ }
106 | .media-symbol.media-unmute-symbol { /* unmute svg */ }
107 |
108 | .media-text.media-current-time { /* plain text */ }
109 | .media-text.media-remaining-time { /* plain text */ }
110 |
111 | .media-slider.media-volume {}
112 | .media-range.media-volume-range { /* full volume */ }
113 | .media-range.media-volume-meter { /* current volume */ }
114 |
115 | .media-slider.media-time {}
116 | .media-range.media-time-range { /* full duration */ }
117 | .media-range.media-time-meter { /* elapsed time */ }
118 |
119 | .media-control.media-download {}
120 | .media-symbol.media-download-symbol { /* download svg */ }
121 |
122 | .media-control.media-fullscreen {}
123 | .media-symbol.media-enterfullscreen-symbol { /* enter full screen svg */ }
124 | .media-symbol.media-leavefullscreen-symbol { /* leave full screen svg */ }
125 | ```
126 |
127 | Note the convenience classes like `media-control`, `media-symbol`,
128 | `media-slider`, `media-range` and `media-range-meter`. These make it easier to
129 | style a group of controls or to add new controls.
130 |
131 | Because flexbox is used to arrange the controls, the `order` property can be
132 | used to easily rearrange them.
133 |
134 | The `lang` object is used to provide accessible labels to each control in any
135 | language. Undefined labels will use English labels.
136 |
137 | The `svgs` object is used to assign SVG sprites to each control in each state.
138 | There may be IE limitations when using external sources with your SVGs, so read
139 | [SVG `use` with External Source] for details and solutions.
140 |
141 | ## Media Player Instance
142 |
143 | All DOM generated by [Media Player] is easily accessible from its instance.
144 |
145 | ```js
146 | new Media(mediaElement, options);
147 | ```
148 |
149 | - `media`: the original media target
150 | - `toolbar`: the toolbar containing all the media controls
151 | - `play`: the play button
152 | - `playSymbol`: the play image
153 | - `pauseSymbol`: the pause image
154 | - `mute`: the mute button
155 | - `muteSymbol`: the image seen before clicking mute
156 | - `unmuteSymbol`: the image used after clicking mute
157 | - `currentTime`: the current time element
158 | - `currentTimeText`: the current time text node
159 | - `remainingTime`: the remaining time element
160 | - `remainingTimeText`: the remaining time text node
161 | - `time`: the time slider
162 | - `timeRange`: the time slider range
163 | - `timeMeter`: the time slider meter
164 | - `volume`: the volume slider
165 | - `volumeRange`: the volume slider range
166 | - `volumeMeter`: the volume slider meter
167 | - `download`: the download button
168 | - `downloadSymbol`: the download image
169 | - `enterFullscreenSymbol`: the full screen enter image
170 | - `leaveFullscreenSymbol`: the full screen leave image
171 |
172 | ## Sliders
173 |
174 | Volume and time controls sliders allow you to drag volume up or down and time
175 | backward or forward. By default, these sliders work left-to-right, meaning that
176 | dragging the slider to the right advances the control. This behavior is
177 | expected in a right-to-left environment as well.
178 |
179 | These control can be configured to work in any direction — right-to-left
180 | (`rtl`), top-to-bottom (`ttb`), bottom-to-top (`btt`) — with the following
181 | configuration:
182 |
183 | ```js
184 | new MediaPlayer(audioElement, {
185 | volumeDir: 'btt', // (volume will drag bottom to top)
186 | timeDir: 'ttb' // (time will drag top to bottom)
187 | });
188 | ```
189 |
190 | ## Accessibility
191 |
192 | [Media Player] automatically assigns ARIA roles and labels to
193 | its controls, which always reflect the present state of the media player.
194 | Special slider roles are used for volume and time which are then given helpful
195 | keyboard controls.
196 |
197 | ## Events
198 |
199 | Three new events are dispatched by [Media Player]. `playchange`
200 | dispatches whenever play or pause toggles. `timechange` dispatches more rapidly
201 | than `timeupdate`. `canplaystart` dispatches the first time media can play
202 | through.
203 |
204 | ## Keyboard Controls
205 |
206 | ### Spacebar, Enter / Return
207 |
208 | When focused on the **play button** or the **timeline slider**, pressing the
209 | **spacebar** or **enter / return** toggles the playback of the media.
210 |
211 | When focused on the **mute button** or the **volume slider**, pressing the
212 | **spacebar** or **enter / return** toggles the muting of the media.
213 |
214 | ### Arrows
215 |
216 | When focused on the **play button** or the **timeline slider**, pressing
217 | **right arrow** or **up arrow** moves the time forward, while pressing
218 | **left arrow** or **down arrow** moves the time backward. Time is moved by 10
219 | seconds, unless **shift** is also pressed, in which case it moves 30 seconds.
220 |
221 | When focused on the **mute button** or the **volume slider**, pressing
222 | **right arrow** or **up arrow** increases the volume, while pressing
223 | **left arrow** or **down arrow** decreases the volume. Volume is moved by 1%,
224 | unless **shift** is also pressed, in which case it moves 10%.
225 |
226 | ## Browser compatibility
227 |
228 | [Media Player] works in all browsers supporting Audio, Video, and SVG elements.
229 | This includes Chrome, Edge, Firefox, Internet Explorer 9+, Opera, and Safari 6+.
230 | Older versions of Edge and Internet Explorer may need additional assistance
231 | loading SVGs from an external URL.
232 |
233 | Read [SVG `use` with External Source] for details and solutions.
234 |
235 | ## Demos
236 |
237 | - [Standard Experience](https://jonathantneal.github.io/media-player/)
238 | - [Right-To-Left Experience](https://jonathantneal.github.io/media-player/rtl.html)
239 | - [Playlist Experience](https://jonathantneal.github.io/media-player/playlist.html)
240 | - [Subtitled Experience](https://jonathantneal.github.io/media-player/subtitles.html)
241 |
242 | ## Licensing
243 |
244 | [Media Player] and its icons use the CC0 “No Rights Reserved” license.
245 |
246 | ---
247 |
248 | [Media Player] adds up to 2.47 kB of JS and 582 B of CSS to your project.
249 |
250 | [Media Player]: https://github.com/jonathantneal/media-player
251 |
252 | [cli-img]: https://img.shields.io/travis/jonathantneal/media-player/master.svg
253 | [cli-url]: https://travis-ci.org/jonathantneal/media-player
254 | [fsz-img]: https://img.shields.io/bundlephobia/minzip/mediaplayer.svg
255 | [fsz-url]: https://www.npmjs.com/package/mediaplayer
256 | [npm-img]: https://img.shields.io/npm/v/mediaplayer.svg
257 | [npm-url]: https://www.npmjs.com/package/mediaplayer
258 |
259 | [PostCSS Import]: https://github.com/postcss/postcss-import
260 | [RTL Demo]: https://jonathantneal.github.io/media-player/rtl.html
261 | [SVG `use` with External Source]: https://css-tricks.com/svg-use-external-source/
262 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "es6": true,
6 | "node": true
7 | },
8 | "parser": "babel-eslint",
9 | "parserOptions": {
10 | "ecmaVersion": 2018,
11 | "impliedStrict": true,
12 | "sourceType": "module"
13 | },
14 | "extends": "eslint:recommended"
15 | };
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mediaplayer",
3 | "version": "2.0.1",
4 | "description": "A tiny, responsive, international, accessible, easily customizable media player",
5 | "author": "Jonathan Neal ",
6 | "license": "CC0-1.0",
7 | "repository": "jonathantneal/media-player",
8 | "homepage": "https://github.com/jonathantneal/media-player#readme",
9 | "bugs": "https://github.com/jonathantneal/media-player/issues",
10 | "main": "index.js",
11 | "module": "index.mjs",
12 | "browser": "browser.js",
13 | "style": "index.css",
14 | "files": [
15 | "browser.css",
16 | "browser.js",
17 | "index.css",
18 | "index.js",
19 | "index.mjs"
20 | ],
21 | "scripts": {
22 | "build": "npm run build:css && npm run build:js",
23 | "build:css": "npm run build:css:node && npm run build:css:browser",
24 | "build:css:node": "postcss src/index.css --output index.css && gzip-size index.css",
25 | "build:css:browser": "postcss src/index.css --browser --output browser.css && gzip-size browser.css",
26 | "build:js": "npm run build:js:node && npm run build:js:browser",
27 | "build:js:node": "cross-env NODE_ENV=node rollup --config && gzip-size index.js",
28 | "build:js:browser": "cross-env NODE_ENV=browser,min rollup --config && gzip-size browser.js",
29 | "prepublishOnly": "npm run test && npm run build",
30 | "start": "concurrently -k npm:start:*",
31 | "start:css": "postcss src/index.css --watch --gh-pages --output .gh-pages/media-player.css",
32 | "start:http": "http-server .gh-pages",
33 | "start:js": "cross-env NODE_ENV=gh-pages,min rollup --config rollup.config.js --watch",
34 | "test": "npm run test:css && npm run test:js",
35 | "test:css": "stylelint src/*.css --cache",
36 | "test:js": "eslint src/*.js --config eslint.config.js --cache"
37 | },
38 | "engines": {
39 | "node": ">=6.0.0"
40 | },
41 | "devDependencies": {
42 | "@babel/core": "^7.4.0",
43 | "@babel/preset-env": "^7.4.2",
44 | "babel-eslint": "^10.0.1",
45 | "concurrently": "^4.1.0",
46 | "cross-env": "^5.2.0",
47 | "cssnano": "^4.1.10",
48 | "eslint": "^5.16.0",
49 | "gzip-size": "^5.0.0",
50 | "http-server": "^0.11.1",
51 | "postcss-calc": "^7.0.1",
52 | "postcss-cli": "^6.1.2",
53 | "postcss-merge-rules": "^4.0.3",
54 | "postcss-preset-env": "^6.6.0",
55 | "pre-commit": "^1.2.2",
56 | "rollup": "^1.7.4",
57 | "rollup-plugin-babel": "^4.3.2",
58 | "rollup-plugin-terser": "^4.0.4",
59 | "stylelint": "^9.10.1",
60 | "stylelint-config-dev": "^4.0.0"
61 | },
62 | "keywords": [
63 | "media",
64 | "audio",
65 | "video",
66 | "player",
67 | "customizable",
68 | "international",
69 | "accessible",
70 | "cross browser",
71 | "aria",
72 | "dom",
73 | "controls"
74 | ]
75 | }
76 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = ctx => ({
2 | map: !process.argv.includes('--browser'),
3 | plugins: [
4 | // future compatibility
5 | require('postcss-preset-env')({
6 | features: {
7 | 'color-mod-function': { unresolved: 'warn' },
8 | 'custom-properties': { preserve: false }
9 | },
10 | stage: 0
11 | })
12 | ].concat(
13 | // neatness and compression
14 | process.argv.includes('--browser') || process.argv.includes('--gh-pages') ? [
15 | require('cssnano')({
16 | normalizeUrl: false,
17 | preset: ['default', {
18 | normalizeUrl: false
19 | }]
20 | }),
21 | compress()
22 | ] : [
23 | require('postcss-discard-comments')(),
24 | require('postcss-discard-duplicates')(),
25 | require('postcss-discard-overridden')(),
26 | require('postcss-merge-rules')(),
27 | require('postcss-calc')(),
28 | compress(),
29 | prettier()
30 | ]
31 | )
32 | });
33 |
34 | // tooling
35 | const postcss = require('postcss');
36 |
37 | // plugin
38 | const compress = postcss.plugin('postcss-discard-tested-duplicate-declarations', (opts) => (root) => {
39 | const testProp = opts && 'testProp' in opts ? opts.testProp : (prop) => !/^:*-/.test(prop);
40 | const testValue = opts && 'testValue' in opts ? opts.testValue : (value) => !/(^var|^\s*-|\s+-\w+-)/.test(value);
41 |
42 | root.walkRules((rule) => {
43 | var propsMap = {};
44 |
45 | rule.nodes.slice(0).forEach((decl) => {
46 | if (testProp(decl.prop) && testValue(decl.value)) {
47 | const prevDecl = propsMap[decl.prop];
48 |
49 | if (prevDecl) {
50 | if (testValue(prevDecl.value)) {
51 | if (decl.import || !prevDecl.import) {
52 | prevDecl.remove();
53 |
54 | propsMap[decl.prop] = decl;
55 | } else {
56 | decl.remove();
57 | }
58 | }
59 | } else {
60 | propsMap[decl.prop] = decl;
61 | }
62 | }
63 | })
64 | });
65 | });
66 |
67 | // plugin
68 | const prettier = postcss.plugin('postcss-prettier', () => root => {
69 | const raws = {
70 | decl: {
71 | before: '\n\t',
72 | between: ': '
73 | },
74 | rule: {
75 | before: '\n\n',
76 | between: ' ',
77 | semicolon: true,
78 | after: '\n'
79 | }
80 | };
81 |
82 | root.walk(node => {
83 | node.raws = Object.assign({}, raws[node.type]);
84 |
85 | if (node.type === 'rule') {
86 | if (node.parent.first === node) {
87 | node.raws.before = '';
88 | }
89 |
90 | node.nodes = node.nodes.sort(
91 | (a, b) => a.prop < b.prop
92 | ? -1
93 | : a.prop > b.prop
94 | ? 1
95 | : 0
96 | );
97 | }
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babelPresetEnv from '@babel/preset-env';
2 | import rollupPluginBabel from 'rollup-plugin-babel';
3 | import { terser as rollupPluginTerser } from 'rollup-plugin-terser';
4 |
5 | const isBrowser = String(process.env.NODE_ENV).includes('browser');
6 | const isGhpages = String(process.env.NODE_ENV).includes('gh-pages');
7 | const isMinified = String(process.env.NODE_ENV).includes('min');
8 |
9 | console.log({ isGhpages });
10 |
11 | const output = isGhpages ? [
12 | { file: '.gh-pages/media-player.js', format: 'iife', name: 'MediaPlayer', sourcemap: 'inline', strict: false }
13 | ] : isBrowser ? [
14 | { file: 'browser.js', format: 'iife', name: 'MediaPlayer', strict: false }
15 | ] : [
16 | { file: 'index.js', format: 'cjs', strict: false },
17 | { file: 'index.mjs', format: 'esm', strict: false }
18 | ];
19 |
20 | const plugins = [
21 | rollupPluginBabel({
22 | babelrc: false,
23 | presets: [
24 | [babelPresetEnv, {
25 | corejs: 3,
26 | loose: true,
27 | modules: false,
28 | useBuiltIns: 'entry'
29 | }]
30 | ]
31 | })
32 | ].concat(isMinified
33 | ? rollupPluginTerser()
34 | : []);
35 |
36 | export default { input: 'src/index.js', output, plugins };
37 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --player-enter-color: color-mod(#9999ff alpha(25%));
3 | --player-back-fullscreen-color: color-mod(#000000 alpha(75%));
4 | --player-fill-fullscreen-color: #ffffff;
5 | --player-range-color: #cccccc;
6 | --player-meter-color: #0088dd;
7 | }
8 |
9 | .media-toolbar {
10 | align-items: center;
11 | cursor: default;
12 | direction: ltr;
13 | display: flex;
14 | flex-wrap: wrap;
15 |
16 | @nest :fullscreen & {
17 | background-color: var(--player-back-fullscreen-color);
18 | color: var(--player-fill-fullscreen-color);
19 | inset-block-end: 0;
20 | inset-inline: 0;
21 | opacity: .8;
22 | position: absolute;
23 | }
24 | }
25 |
26 | .media-hidden {
27 | display: none;
28 | }
29 |
30 | .media-media {
31 | display: block;
32 | margin-inline: auto;
33 | max-height: 100vh;
34 | max-width: 100%;
35 | position: relative;
36 | }
37 |
38 | .media-control, .media-slider {
39 | background-color: transparent;
40 | border-style: none;
41 | color: inherit;
42 | font: inherit;
43 | margin: 0;
44 | overflow: visible;
45 | padding: 0;
46 | -webkit-tap-highlight-color: transparent; /* stylelint-disable-line property-no-vendor-prefix */
47 | -webkit-touch-callout: none; /* stylelint-disable-line property-no-vendor-prefix */
48 | -webkit-user-select: none; /* stylelint-disable-line property-no-vendor-prefix */
49 | }
50 |
51 | .media-slider {
52 | height: 2.5em;
53 | padding: .625em .5em;
54 |
55 | &:focus {
56 | background-color: var(--player-enter-color);
57 | }
58 | }
59 |
60 | .media-time {
61 | flex-grow: 1;
62 | flex-shrink: 1;
63 | }
64 |
65 | .media-volume {
66 | flex-basis: 5em;
67 | }
68 |
69 | .media-range {
70 | background-color: var(--player-range-color);
71 | display: block;
72 | font-size: 75%;
73 | height: 1em;
74 | width: 100%;
75 | }
76 |
77 | .media-meter {
78 | background-color: var(--player-meter-color);
79 | display: block;
80 | height: 100%;
81 | overflow: hidden;
82 | width: 100%;
83 | }
84 |
85 | .media-text {
86 | font-size: 75%;
87 | padding-inline: .5em;
88 | width: 2.5em;
89 | }
90 |
91 | .media-control {
92 | font-size: 75%;
93 | line-height: 1;
94 | padding: 1.16667em;
95 | text-decoration: none;
96 |
97 | &:matches(:hover, :focus) {
98 | background-color: var(--player-enter-color);
99 | }
100 | }
101 |
102 | .media-symbol {
103 | display: block;
104 | fill: currentColor;
105 | height: 1em;
106 | width: 1em;
107 |
108 | &:matches([aria-hidden="true"]) {
109 | display: none;
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | /* Media Player
2 | /* ====================================================================== */
3 |
4 | export default function MediaPlayer(media, rawopts) { // eslint-disable-line complexity
5 | /* Options
6 | /* ====================================================================== */
7 |
8 | const self = this;
9 | const opts = Object(rawopts);
10 | const prefix = opts.prefix || 'media';
11 | const lang = Object(opts.lang);
12 | const svgs = Object(opts.svgs);
13 |
14 | const timeDir = opts.timeDir || 'ltr';
15 | const volumeDir = opts.volumeDir || 'ltr';
16 |
17 | /* Elements
18 | /* ====================================================================== */
19 |
20 | const document = media.ownerDocument;
21 |
22 | self.media = $(media, {
23 | canplaythrough: onCanPlayStart,
24 | loadstart: onLoadStart,
25 | loadeddata: onLoadedData,
26 | pause: onPlayChange,
27 | play: onPlayChange,
28 | timeupdate: onTimeChange,
29 | volumechange: onVolumeChange
30 | });
31 |
32 | // play/pause toggle
33 | self.playSymbol = svg(prefix, svgs, 'play');
34 | self.pauseSymbol = svg(prefix, svgs, 'pause');
35 | self.play = $('button', { class: `${prefix}-control ${prefix}-play`, click: onPlayClick, keydown: onTimeKeydown }, self.playSymbol, self.pauseSymbol);
36 |
37 | // time slider
38 | self.timeMeter = $('span', { class: `${prefix}-meter ${prefix}-time-meter` });
39 | self.timeRange = $('span', { class: `${prefix}-range ${prefix}-time-range` }, self.timeMeter);
40 | self.time = $('button', { class: `${prefix}-slider ${prefix}-time`, role: 'slider', 'aria-label': lang.currentTime || 'current time', 'data-dir': timeDir, click: onTimeClick, keydown: onTimeKeydown }, self.timeRange);
41 |
42 | // current time text
43 | self.currentTimeText = document.createTextNode('');
44 | self.currentTime = $('span', { class: `${prefix}-text ${prefix}-current-time`, role: 'timer', 'aria-label': lang.currentTime || 'current time' }, self.currentTimeText);
45 |
46 | // remaining time text
47 | self.remainingTimeText = document.createTextNode('');
48 | self.remainingTime = $('span', { class: `${prefix}-text ${prefix}-remaining-time`, role: 'timer', 'aria-label': lang.remainingTime || 'remaining time' }, self.remainingTimeText);
49 |
50 | // mute/unmute toggle
51 | self.muteSymbol = svg(prefix, svgs, 'mute');
52 | self.unmuteSymbol = svg(prefix, svgs, 'unmute');
53 | self.mute = $('button', { class: `${prefix}-control ${prefix}-mute`, click: onMuteClick, keydown: onVolumeKeydown }, self.muteSymbol, self.unmuteSymbol);
54 |
55 | // volume slider
56 | self.volumeMeter = $('span', { class: `${prefix}-meter ${prefix}-volume-meter` });
57 | self.volumeRange = $('span', { class: `${prefix}-range ${prefix}-volume-range` }, self.volumeMeter);
58 | self.volume = $('button', { class: `${prefix}-slider ${prefix}-volume`, role: 'slider', 'aria-label': lang.volume || 'volume', 'data-dir': volumeDir, click: onVolumeClick, keydown: onVolumeKeydown }, self.volumeRange);
59 |
60 | // download button
61 | self.downloadSymbol = svg(prefix, svgs, 'download');
62 | self.download = $('button', { class: `${prefix}-control ${prefix}-download`, 'aria-label': lang.download || 'download', click: onDownloadClick }, self.downloadSymbol);
63 |
64 | // fullscreen button
65 | self.enterFullscreenSymbol = svg(prefix, svgs, 'enterFullscreen');
66 | self.leaveFullscreenSymbol = svg(prefix, svgs, 'leaveFullscreen');
67 | self.fullscreen = $('button', { class: `${prefix}-control ${prefix}-fullscreen`, click: onFullscreenClick }, self.enterFullscreenSymbol, self.leaveFullscreenSymbol);
68 |
69 | // player toolbar
70 | self.toolbar = $('div',
71 | { class: `${prefix}-toolbar`, role: 'toolbar', 'aria-label': lang.player || 'media player' },
72 | self.play, self.mute,self.volume, self.currentTime, self.time, self.remainingTime, self.download, self.fullscreen
73 | );
74 |
75 | // player
76 | const player = self.player = $('div', { class: `${prefix}-player`, role: 'region', 'aria-label': lang.player || 'media player' }, self.toolbar);
77 |
78 | // fullscreen api
79 | const fullscreenchange = self._fullscreenchange = 'onfullscreenchange' in player ? 'fullscreenchange' : 'onwebkitfullscreenchange' in player ? 'webkitfullscreenchange' : 'onmozfullscreenchange' in player ? 'mozfullscreenchange' : 'onMSFullscreenChange' in player ? 'MSFullscreenChange' : 'fullscreenchange';
80 | const fullscreenElement = self._fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
81 | const requestFullscreen = self._requestFullscreen = player.requestFullscreen || player.webkitRequestFullscreen || player.mozRequestFullScreen || player.msRequestFullscreen;
82 | const exitFullscreen = self._exitFullscreen = () => document.exitFullscreen || document.webkitCancelFullScreen || document.mozCancelFullScreen || document.msExitFullscreen;
83 |
84 | // listen for fullscreen changes
85 | document.addEventListener(fullscreenchange, onFullscreenChange);
86 |
87 | // update media class and controls
88 | $(media, { class: `${prefix}-media`, playsinline: '', 'webkit-playsinline': '', role: 'img', tabindex: -1 }).controls = false;
89 |
90 | // replace the media element with the media player
91 | if (media.parentNode) {
92 | player.insertBefore(
93 | media.parentNode.replaceChild(player, media),
94 | self.toolbar
95 | );
96 | }
97 |
98 | /* Interval Events
99 | /* ====================================================================== */
100 |
101 | let paused, currentTime, duration, interval;
102 |
103 | // when the play state changes
104 | function onPlayChange() {
105 | if (paused !== media.paused) {
106 | paused = media.paused;
107 |
108 | $(self.play, { 'aria-label': paused ? lang.play || 'play' : lang.pause || 'pause' });
109 | $(self.playSymbol, { 'aria-hidden': !paused });
110 | $(self.pauseSymbol, { 'aria-hidden': paused });
111 |
112 | clearInterval(interval);
113 |
114 | if (!paused) {
115 | // listen for time changes every 30th of a second
116 | interval = setInterval(onTimeChange, 34);
117 | }
118 |
119 | // dispatch new "playchange" event
120 | dispatchCustomEvent(media, 'playchange');
121 | }
122 | }
123 |
124 | // when the time changes
125 | function onTimeChange() {
126 | if (currentTime !== media.currentTime || duration !== media.duration) {
127 | currentTime = media.currentTime;
128 | duration = media.duration || 0;
129 |
130 | const currentTimePercentage = currentTime / duration;
131 | const currentTimeCode = timeToTimecode(currentTime);
132 | const remainingTimeCode = timeToTimecode(duration - Math.floor(currentTime));
133 |
134 | if (currentTimeCode !== self.currentTimeText.nodeValue) {
135 | self.currentTimeText.nodeValue = currentTimeCode;
136 |
137 | $(self.currentTime, { title: `${timeToAural(currentTime, lang.minutes || 'minutes', lang.seconds || 'seconds')}` });
138 | }
139 |
140 | if (remainingTimeCode !== self.remainingTimeText.nodeValue) {
141 | self.remainingTimeText.nodeValue = remainingTimeCode;
142 |
143 | $(self.remainingTime, { title: `${timeToAural(duration - currentTime, lang.minutes || 'minutes', lang.seconds || 'seconds')}` });
144 | }
145 |
146 | $(self.time, { 'aria-valuenow': currentTime, 'aria-valuemin': 0, 'aria-valuemax': duration });
147 |
148 | const dirIsInline = /^(ltr|rtl)$/i.test(timeDir);
149 | const axisProp = dirIsInline ? 'width' : 'height';
150 |
151 | self.timeMeter.style[axisProp] = `${currentTimePercentage * 100}%`;
152 |
153 | // dispatch new "timechange" event
154 | dispatchCustomEvent(media, 'timechange');
155 | }
156 | }
157 |
158 | // when media loads for the first time
159 | function onLoadStart() {
160 | media.removeEventListener('canplaythrough', onCanPlayStart);
161 |
162 | $(media, { canplaythrough: onCanPlayStart });
163 |
164 | $(self.download, { href: media.src, download: media.src });
165 |
166 | onPlayChange();
167 | onVolumeChange();
168 | onFullscreenChange();
169 | onTimeChange();
170 | }
171 |
172 | // when the immediate current playback position is available
173 | function onLoadedData() {
174 | onTimeChange();
175 | }
176 |
177 | // when the media can play
178 | function onCanPlayStart() {
179 | media.removeEventListener('canplaythrough', onCanPlayStart);
180 |
181 | // dispatch new "canplaystart" event
182 | dispatchCustomEvent(media, 'canplaystart');
183 |
184 | if (!paused || media.autoplay) {
185 | media.play();
186 | }
187 | }
188 |
189 | // when the volume changes
190 | function onVolumeChange() {
191 | const volumePercentage = media.muted ? 0 : media.volume;
192 | const isMuted = !volumePercentage;
193 |
194 | $(self.volume, { 'aria-valuenow': volumePercentage, 'aria-valuemin': 0, 'aria-valuemax': 1 });
195 |
196 | const dirIsInline = /^(ltr|rtl)$/i.test(volumeDir);
197 | const axisProp = dirIsInline ? 'width' : 'height';
198 |
199 | self.volumeMeter.style[axisProp] = `${volumePercentage * 100}%`;
200 |
201 | $(self.mute, { 'aria-label': isMuted ? lang.unmute || 'unmute' : lang.mute || 'mute' });
202 | $(self.muteSymbol, { 'aria-hidden': isMuted });
203 | $(self.unmuteSymbol, { 'aria-hidden': !isMuted });
204 | }
205 |
206 | function onFullscreenChange() {
207 | const isFullscreen = player === fullscreenElement();
208 |
209 | $(self.fullscreen, { 'aria-label': isFullscreen ? lang.leaveFullscreen || 'leave full screen' : lang.enterFullscreen || 'enter full screen' });
210 | $(self.enterFullscreenSymbol, { 'aria-hidden': isFullscreen });
211 | $(self.leaveFullscreenSymbol, { 'aria-hidden': !isFullscreen });
212 | }
213 |
214 | /* Input Events
215 | /* ====================================================================== */
216 |
217 | // when the play control is clicked
218 | function onPlayClick(event) {
219 | event.preventDefault();
220 |
221 | media[media.paused ? 'play' : 'pause']();
222 | }
223 |
224 | // when the time control
225 | function onTimeClick(event) {
226 | // handle click if clicked without pointer
227 | if (!event.pointerType && !event.detail) {
228 | onPlayClick(event);
229 | }
230 | }
231 |
232 | // click from mute control
233 | function onMuteClick(event) {
234 | event.preventDefault();
235 |
236 | media.muted = !media.muted;
237 | }
238 |
239 | // click from volume control
240 | function onVolumeClick(event) {
241 | // handle click if clicked without pointer
242 | if (!event.pointerType && !event.detail) {
243 | onMuteClick(event);
244 | }
245 | }
246 |
247 | // click from download control
248 | function onDownloadClick() {
249 | const a = document.head.appendChild($('a', { download: '', href: media.src }));
250 |
251 | a.click();
252 |
253 | document.head.removeChild(a);
254 | }
255 |
256 | // click from fullscreen control
257 | function onFullscreenClick() {
258 | if (requestFullscreen) {
259 | if (player === fullscreenElement()) {
260 | // exit fullscreen
261 | exitFullscreen().call(document);
262 | } else {
263 | // enter fullscreen
264 | requestFullscreen.call(player);
265 |
266 | // maintain focus in internet explorer
267 | self.fullscreen.focus();
268 |
269 | // maintain focus in safari
270 | setTimeout(() => {
271 | self.fullscreen.focus();
272 | }, 200);
273 | }
274 | } else if (media.webkitSupportsFullscreen) {
275 | // iOS allows fullscreen of the video itself
276 | if (media.webkitDisplayingFullscreen) {
277 | // exit ios fullscreen
278 | media.webkitExitFullscreen();
279 | } else {
280 | // enter ios fullscreen
281 | media.webkitEnterFullscreen();
282 | }
283 |
284 | onFullscreenChange();
285 | }
286 | }
287 |
288 | // keydown from play control or current time control
289 | function onTimeKeydown(event) {
290 | const { keyCode, shiftKey } = event;
291 |
292 | // 37: LEFT, 38: UP, 39: RIGHT, 40: DOWN
293 | if (37 <= keyCode && 40 >= keyCode) {
294 | event.preventDefault();
295 |
296 | const isLTR = /^(btt|ltr)$/.test(timeDir);
297 | const offset = 37 === keyCode || 39 === keyCode ? keyCode - 38 : keyCode - 39;
298 |
299 | media.currentTime = Math.max(0, Math.min(duration, currentTime + offset * (isLTR ? 1 : -1) * (shiftKey ? 10 : 1)));
300 |
301 | onTimeChange();
302 | }
303 | }
304 |
305 | // keydown from mute control or volume control
306 | function onVolumeKeydown(event) {
307 | const { keyCode, shiftKey } = event;
308 |
309 | // 37: LEFT, 38: UP, 39: RIGHT, 40: DOWN
310 | if (37 <= keyCode && 40 >= keyCode) {
311 | event.preventDefault();
312 |
313 | const isLTR = /^(btt|ltr)$/.test(volumeDir);
314 | const offset = 37 === keyCode || 39 === keyCode ? keyCode - 38 : isLTR ? 39 - keyCode : keyCode - 39;
315 |
316 | media.volume = Math.max(0, Math.min(1, media.volume + offset * (isLTR ? 0.1 : -0.1) * (shiftKey ? 1 : 0.2)));
317 | }
318 | }
319 |
320 | // pointer events from time control
321 | onDrag(self.time, self.timeRange, timeDir, (percentage) => {
322 | media.currentTime = duration * Math.max(0, Math.min(1, percentage));
323 |
324 | onTimeChange();
325 | });
326 |
327 | // pointer events from volume control
328 | onDrag(self.volume, self.volumeRange, volumeDir, (percentage) => {
329 | media.volume = Math.max(0, Math.min(1, percentage));
330 | });
331 |
332 | onLoadStart();
333 | }
334 |
335 | /* Handle Drag Ranges
336 | /* ========================================================================== */
337 |
338 | function onDrag(target, innerTarget, dir, listener) { // eslint-disable-line max-params
339 | const hasPointerEvent = undefined !== target.onpointerup;
340 | const hasTouchEvent = undefined !== target.ontouchstart;
341 | const pointerDown = hasPointerEvent ? 'pointerdown' : hasTouchEvent ? 'touchstart' : 'mousedown';
342 | const pointerMove = hasPointerEvent ? 'pointermove' : hasTouchEvent ? 'touchmove' : 'mousemove';
343 | const pointerUp = hasPointerEvent ? 'pointerup' : hasTouchEvent ? 'touchend' : 'mouseup';
344 |
345 | // ...
346 | const dirIsInline = /^(ltr|rtl)$/i.test(dir);
347 | const dirIsStart = /^(ltr|ttb)$/i.test(dir);
348 |
349 | // ...
350 | const axisProp = dirIsInline ? 'clientX' : 'clientY';
351 |
352 | let window, start, end;
353 |
354 | // on pointer down
355 | target.addEventListener(pointerDown, onpointerdown);
356 |
357 | function onpointerdown(event) {
358 | // window
359 | window = target.ownerDocument.defaultView;
360 |
361 | // client boundaries
362 | const rect = innerTarget.getBoundingClientRect();
363 |
364 | // the container start and end coordinates
365 | start = dirIsInline ? rect.left : rect.top;
366 | end = dirIsInline ? rect.right : rect.bottom;
367 |
368 | onpointermove(event);
369 |
370 | window.addEventListener(pointerMove, onpointermove);
371 | window.addEventListener(pointerUp, onpointerup);
372 | }
373 |
374 | function onpointermove(event) {
375 | // prevent browser actions on this event
376 | event.preventDefault();
377 |
378 | // the pointer coordinate
379 | const position = axisProp in event ? event[axisProp] : event.touches && event.touches[0] && event.touches[0][axisProp] || 0;
380 |
381 | // the percentage of the pointer along the container
382 | const percentage = (dirIsStart ? position - start : end - position) / (end - start);
383 |
384 | // call the listener with percentage
385 | listener(percentage);
386 | }
387 |
388 | function onpointerup() {
389 | window.removeEventListener(pointerMove, onpointermove);
390 | window.removeEventListener(pointerUp, onpointerup);
391 | }
392 | }
393 |
394 | /* Time To Timecode
395 | /* ====================================================================== */
396 |
397 | function timeToTimecode(time) {
398 | return `${`0${Math.floor(time / 60)}`.slice(-2)}:${`0${Math.floor(time % 60)}`.slice(-2)}`;
399 | }
400 |
401 | /* Time To Aural
402 | /* ====================================================================== */
403 |
404 | function timeToAural(time, langMinutes, langSeconds) {
405 | return `${Math.floor(time / 60)} ${langMinutes}, ${Math.floor(time % 60)} ${langSeconds}`;
406 | }
407 |
408 | /* Update Elements
409 | /* ========================================================================== */
410 |
411 | function $(rawid) {
412 | const id = rawid instanceof Node ? rawid : document.createElement(rawid);
413 | const args = [].slice.call(arguments, 1);
414 |
415 | for (let index in args) {
416 | if (args[index] instanceof Node) {
417 | id.appendChild(args[index]);
418 | } else if (Object(args[index]) === args[index]) {
419 | for (let attr in args[index]) {
420 | if ('function' === typeof args[index][attr]) {
421 | id.addEventListener(attr, args[index][attr]);
422 | } else {
423 | id.setAttribute(attr, args[index][attr]);
424 | }
425 | }
426 | }
427 | }
428 |
429 | return id;
430 | }
431 |
432 | function svg(prefix, svgs, type) { // eslint-disable-line max-params
433 | const svgns = 'http://www.w3.org/2000/svg';
434 | const use = document.createElementNS(svgns, 'use');
435 |
436 | use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', svgs[type] || `#symbol-${type}`);
437 |
438 | return $(document.createElementNS(svgns, 'svg'), {
439 | class: `${prefix}-symbol ${prefix}-${type}-symbol`,
440 | role: 'presentation'
441 | }, use);
442 | }
443 |
444 | /* Dispatch Events
445 | /* ====================================================================== */
446 |
447 | function dispatchCustomEvent(node, type) {
448 | const event = document.createEvent('CustomEvent');
449 |
450 | event.initCustomEvent(type, true, true, undefined);
451 |
452 | node.dispatchEvent(event);
453 | }
454 |
--------------------------------------------------------------------------------
/stylelint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: 'stylelint-config-dev'
3 | };
4 |
--------------------------------------------------------------------------------