├── .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 | Media Player Screenshot 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 | Diagram of Media Player 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 | Diagram of Time Slider 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 | --------------------------------------------------------------------------------