├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── README.md ├── browserslist ├── demo ├── index.html ├── package.json ├── server.js ├── ui.css └── ui.js ├── gulpfile.js ├── package.json ├── snapshots ├── normal.png └── simple.png └── src ├── events.js ├── icons ├── backward.svg ├── forward.svg ├── list.svg ├── pause.svg ├── play.svg ├── repeat-off.svg ├── repeat-one.svg └── repeat.svg ├── index.js ├── lyric.js ├── progress.js ├── sprite.js ├── style.less └── util.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "modules": false, 5 | "loose": true, 6 | }], 7 | "@babel/preset-stage-2", 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | !src/** 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'airbnb-base', 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | env: { 8 | browser: true, 9 | }, 10 | plugins: [ 11 | 'import' 12 | ], 13 | rules: { 14 | 'no-use-before-define': ['error', 'nofunc'], 15 | 'no-mixed-operators': 0, 16 | 'arrow-parens': 0, 17 | 'no-plusplus': 0, 18 | 'no-param-reassign': 0, 19 | 'consistent-return': 0, 20 | 'no-console': ['warn', { 21 | allow: ['error', 'warn', 'info'], 22 | }], 23 | 'no-bitwise': ['error', { int32Hint: true }], 24 | indent: 'off', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | *.lock 4 | /.idea 5 | /dist 6 | /src/temp/** 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo 2 | snapshots 3 | src 4 | gulpfile.js 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## H5Player 2 | 3 | ![NPM](https://img.shields.io/npm/v/h5player.svg) 4 | ![License](https://img.shields.io/npm/l/h5player.svg) 5 | 6 | ### Installation 7 | 8 | ``` sh 9 | $ yarn add h5player 10 | # or 11 | $ npm install h5player -S 12 | ``` 13 | 14 | ### Usage 15 | 16 | 1. Load `h5player` 17 | 18 | * Via global 19 | 20 | ``` html 21 | 22 | 23 | 26 | ``` 27 | 28 | * Via CMD 29 | 30 | ``` javascript 31 | const H5Player = require('h5player'); 32 | ``` 33 | 34 | * Via ESModule 35 | 36 | ```js 37 | import H5Player from 'h5player'; 38 | ``` 39 | 40 | 2. Create a player and append it to `document.body` (or any mounted element). 41 | 42 | ``` javascript 43 | const player = new H5Player({ 44 | image: 'http://example.com/path/to/default/image', 45 | getLyric: (song, callback) => { 46 | const lyric = getLyricFromSomewhereElse(song); 47 | callback(lyric); 48 | }, 49 | }); 50 | document.body.appendChild(player.el); 51 | 52 | player.setSongs([ 53 | { 54 | name: 'Song1', 55 | url: 'http://example.com/path/to/song1.mp3', 56 | additionalInfo: 'whatever', 57 | }, { 58 | name: 'Song2', 59 | url: 'http://example.com/path/to/song2.mp3', 60 | } 61 | ]); 62 | player.play(0); 63 | ``` 64 | 65 | ### Document 66 | 67 | Each player is built with `player = new H5Player(options)`. *options* is an object with properties below: 68 | 69 | * `theme`: *optional* string 70 | 71 | Possible values are `normal` (by default) and `simple`. 72 | Can be changed by `player.setTheme(theme)`. 73 | 74 | * `mode`: *optional* string 75 | 76 | The repeat mode for the playlist, possible values are `repeatAll` (by default), `repeatOne` and `repeatOff`. 77 | Can be changed by `player.setMode(mode)`. 78 | 79 | * `showPlaylist`: *optional* Boolean 80 | 81 | Whether to show playlist. Can be changed by `player.setPlaylist(show)`. 82 | 83 | * `image`: *optional* string *or* object 84 | 85 | Image shown when no image is assigned for the current song. 86 | It can be a string of the path to the image or an object with theme names as the keys and 87 | image paths as the values. 88 | The recommended image size for **normal** theme is 130 * 130, and 34 * 34 for **simple** theme. 89 | 90 | * `getLyric`: *optional* function 91 | 92 | An async function to get the lyric. There are two parameters for the callback. The first parameter is the song object and the second is a callback to send the lyric to the player. 93 | 94 | The `player` object has following methods: 95 | 96 | * `setSongs`(*Array* songs) 97 | 98 | Set playlist for the player, *songs* is a list of `object`s with properties below: 99 | 100 | * `name`: *required* string 101 | 102 | The name of the song. 103 | 104 | * `url`: *required* string 105 | 106 | A downloadable URL. 107 | 108 | * `artist`: *optional* string 109 | 110 | The name of the artist. 111 | 112 | * `duration`: *optional* integer 113 | 114 | Length of the song in seconds. 115 | 116 | * `image`: *optional* string *or* object 117 | 118 | The image for the current song. Similar to the default image in common settings. 119 | 120 | * `lyric`: *optional* string 121 | 122 | Lyric of the song, e.g. `[00:00]foo\n[00:05]bar\n...`. 123 | 124 | * `play`(*int* index) 125 | 126 | Start playing the *index*-th song. 127 | 128 | * `setTheme`(*string* theme) 129 | 130 | Change theme. 131 | 132 | * `setMode`(*string* mode) 133 | 134 | Change repeat mode. 135 | 136 | * `setPlaylist`(*boolean* show) 137 | 138 | Toggle playlist on / off. 139 | 140 | When the play status is changed, a `PlayerEvent` will be fired with its `detail` set to an object with following attributes: 141 | 142 | * `player` 143 | 144 | The `Player` object that is related to this event 145 | 146 | * `type` 147 | 148 | `'play'` or `'pause'` 149 | 150 | The player is mounted to `player.el`, you need to append it to the container. 151 | 152 | ### Snapshots 153 | 154 | Normal theme: 155 | 156 | ![snapshot](snapshots/normal.png) 157 | 158 | Simple theme: (multiple players) 159 | 160 | ![snapshot](snapshots/simple.png) 161 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | > 0.5% 2 | last 2 versions 3 | Firefox ESR 4 | not dead 5 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | H5Player demo 7 | 8 | 9 | 10 | 11 | 12 | 13 |

H5Player

14 |
Mode:
15 |
16 |
©2015-2016 Gerald
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "1.0.0", 4 | "main": "server.js", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "axios": "^0.18.0", 9 | "koa": "^2.5.0", 10 | "koa-send": "^4.1.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /demo/server.js: -------------------------------------------------------------------------------- 1 | const Koa = require('koa'); 2 | const send = require('koa-send'); 3 | const axios = require('axios'); 4 | 5 | const PORT = 4009; 6 | const app = new Koa(); 7 | 8 | app.use(async (ctx, next) => { 9 | const { path } = ctx; 10 | if (path.startsWith('/dist/')) { 11 | return send(ctx, path, { root: `${__dirname}/..` }); 12 | } else { 13 | return send(ctx, path, { root: __dirname }); 14 | } 15 | }); 16 | 17 | app.listen(PORT, 'localhost', () => { 18 | console.log(`Listen at http://localhost:${PORT}`); 19 | }); 20 | -------------------------------------------------------------------------------- /demo/ui.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | body { 8 | max-width: 500px; 9 | margin: 1em auto; 10 | font-family: Microsoft YaHei; 11 | } 12 | 13 | h1 { 14 | text-align: center; 15 | } 16 | 17 | #players > * { 18 | margin: 1em auto; 19 | } 20 | -------------------------------------------------------------------------------- /demo/ui.js: -------------------------------------------------------------------------------- 1 | const data = [{ 2 | name: 'My Prayer', 3 | artist: 'Devotion', 4 | url: 'http://localhost:4000/Devotion%20-%20My%20Prayer.mp3', 5 | }]; 6 | const container = document.querySelector('#players'); 7 | let single; 8 | setMode(true); 9 | 10 | document.querySelector('#mode>button') 11 | .addEventListener('click', e => { 12 | setMode(!single); 13 | }, false); 14 | 15 | // custom events 16 | document.addEventListener('PlayerEvent', e => { 17 | const { detail: { type, player } } = e; 18 | const title = [ 19 | type === 'play' && player.songs[player.current].name, 20 | 'H5Player', 21 | ].filter(Boolean).join(' - '); 22 | document.title = title; 23 | }, false); 24 | 25 | function setMode(value) { 26 | single = value; 27 | document.querySelector('#mode>span').textContent = value ? 'Single' : 'Multiple'; 28 | if (value) initSingle(); 29 | else initMultiple(); 30 | } 31 | 32 | function reset() { 33 | container.innerHTML = ''; 34 | } 35 | 36 | function initSingle() { 37 | reset(); 38 | const player = addPlayer('normal'); 39 | player.setSongs(data); 40 | } 41 | 42 | function initMultiple() { 43 | reset(); 44 | first5 = data.slice(0, 5); 45 | first5.forEach(song => { 46 | const player = addPlayer('simple'); 47 | player.setSongs([song]); 48 | }); 49 | } 50 | 51 | function addPlayer(theme) { 52 | const div = document.createElement('div'); 53 | container.appendChild(div); 54 | const player = new H5Player({ 55 | theme, 56 | image: 'http://cn.gravatar.com/avatar/a0ad718d86d21262ccd6ff271ece08a3?s=130', 57 | }); 58 | div.append(player.el); 59 | return player; 60 | } 61 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const gulp = require('gulp'); 3 | const log = require('fancy-log'); 4 | const eslint = require('gulp-eslint'); 5 | const less = require('gulp-less'); 6 | const svgSymbols = require('gulp-svg-symbols'); 7 | const autoprefixer = require('gulp-autoprefixer'); 8 | const rollup = require('rollup'); 9 | const pkg = require('./package.json'); 10 | 11 | const DIST = 'dist'; 12 | const IS_PROD = process.env.NODE_ENV === 'production'; 13 | const values = { 14 | 'process.env.VERSION': pkg.version, 15 | 'process.env.NODE_ENV': process.env.NODE_ENV || 'development', 16 | }; 17 | 18 | 19 | const rollupOptions = { 20 | plugins: [ 21 | { 22 | transform(code, id) { 23 | if (path.extname(id) !== '.svg') return; 24 | return `export default ${JSON.stringify(code)};`; 25 | }, 26 | }, 27 | require('rollup-plugin-babel')({ 28 | runtimeHelpers: true, 29 | exclude: 'node_modules/**', 30 | }), 31 | require('rollup-plugin-replace')({ values }), 32 | ], 33 | }; 34 | 35 | function buildJs() { 36 | return rollup.rollup(Object.assign({ 37 | input: 'src/index.js', 38 | }, rollupOptions)) 39 | .then(bundle => bundle.write({ 40 | name: 'H5Player', 41 | file: `${DIST}/index.js`, 42 | format: 'umd', 43 | })) 44 | .catch(err => { 45 | log(err.toString()); 46 | }); 47 | } 48 | 49 | function buildCss() { 50 | return gulp.src('src/style.less') 51 | .pipe(less()) 52 | .pipe(autoprefixer()) 53 | .pipe(gulp.dest(DIST)); 54 | } 55 | 56 | function buildSvg() { 57 | return gulp.src('src/icons/*.svg') 58 | .pipe(svgSymbols({ 59 | templates: ['default-svg'], 60 | slug: name => `h5p-${name}`, 61 | })) 62 | .pipe(gulp.dest('src/temp')); 63 | } 64 | 65 | function lint() { 66 | return gulp.src('src/**/*.js') 67 | .pipe(eslint()) 68 | .pipe(eslint.format()) 69 | .pipe(eslint.failAfterError()); 70 | } 71 | 72 | function watch() { 73 | gulp.watch('src/**/*.js', buildJs); 74 | gulp.watch('src/**/*.less', buildCss); 75 | } 76 | 77 | const build = gulp.parallel(gulp.series(buildSvg, buildJs), buildCss); 78 | 79 | exports.lint = lint; 80 | exports.build = build; 81 | exports.dev = gulp.series(build, watch); 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "h5player", 3 | "version": "2.0.0", 4 | "description": "A simple but powerful HTML5 music player", 5 | "author": "Gerald ", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "gulp dev", 9 | "build": "gulp build", 10 | "lint": "gulp lint" 11 | }, 12 | "title": "H5Player", 13 | "main": "dist/index.js", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/gera2ld/h5player" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.0.0-beta.40", 20 | "@babel/preset-env": "^7.0.0-beta.40", 21 | "@babel/preset-stage-2": "^7.0.0-beta.40", 22 | "babel-eslint": "^8.2.2", 23 | "eslint": "^4.18.2", 24 | "eslint-config-airbnb-base": "^12.1.0", 25 | "eslint-plugin-import": "^2.9.0", 26 | "fancy-log": "^1.3.2", 27 | "gulp": "^4.0.0", 28 | "gulp-autoprefixer": "^5.0.0", 29 | "gulp-eslint": "^4.0.2", 30 | "gulp-less": "^4.0.0", 31 | "gulp-svg-symbols": "^3.1.0", 32 | "rollup": "^0.56.3", 33 | "rollup-plugin-babel": "^4.0.0-beta.2", 34 | "rollup-plugin-replace": "^2.0.0", 35 | "svgo": "^1.0.5" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /snapshots/normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gera2ld/h5player/7c87a4fada2a5079de97a31d755cce19f8f515e1/snapshots/normal.png -------------------------------------------------------------------------------- /snapshots/simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gera2ld/h5player/7c87a4fada2a5079de97a31d755cce19f8f515e1/snapshots/simple.png -------------------------------------------------------------------------------- /src/events.js: -------------------------------------------------------------------------------- 1 | export default class EventEmitter { 2 | map = {}; 3 | 4 | on(type, handle) { 5 | let handlers = this.map[type]; 6 | if (!handlers) { 7 | handlers = []; 8 | this.map[type] = handlers; 9 | } 10 | handlers.push(handle); 11 | return () => this.off(type, handle); 12 | } 13 | 14 | off(type, handle) { 15 | const handlers = this.map[type]; 16 | if (handlers) { 17 | const i = handlers.indexOf(handle); 18 | if (i >= 0) handlers.splice(i, 1); 19 | } 20 | } 21 | 22 | once(type, handle) { 23 | const revoke = this.on(type, handleOnce); 24 | return revoke; 25 | function handleOnce(...args) { 26 | handle(...args); 27 | revoke(); 28 | } 29 | } 30 | 31 | emit(type, ...args) { 32 | const handlers = this.map[type]; 33 | if (handlers) { 34 | handlers.forEach(handle => { 35 | handle(...args); 36 | }); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/icons/backward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/forward.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/list.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/pause.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/repeat-off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/repeat-one.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/icons/repeat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import LyricParser from './lyric'; 2 | import Progress from './progress'; 3 | import { prevent, createElement, bindEvents, empty, createSVGIcon } from './util'; 4 | import './sprite'; 5 | 6 | const H5P_ACTIVE = 'h5p-active'; 7 | const MODES = [ 8 | 'repeatAll', 9 | 'repeatOne', 10 | 'repeatOff', 11 | ]; 12 | const MODE_ICONS = { 13 | repeatAll: 'h5p-repeat', 14 | repeatOne: 'h5p-repeat-one', 15 | repeatOff: 'h5p-repeat-off', 16 | }; 17 | 18 | // manage all the players to ensure only one is playing at once 19 | const players = []; 20 | let currentPlayer = null; 21 | 22 | function setCurrentPlayer(player) { 23 | currentPlayer = player; 24 | players.forEach(other => { 25 | if (player !== other) other.audio.pause(); 26 | }); 27 | } 28 | 29 | function fireEvent(detail) { 30 | const event = new CustomEvent('PlayerEvent', { 31 | detail, 32 | bubbles: true, 33 | cancelable: true, 34 | }); 35 | document.dispatchEvent(event); 36 | } 37 | 38 | export default class Player { 39 | static themes = [ 40 | 'normal', 41 | 'simple', 42 | ]; 43 | 44 | constructor(options) { 45 | players.push(this); 46 | this.build(options); 47 | this.setSongs([]); 48 | this.setTheme(options.theme); 49 | this.setMode(options.mode); 50 | this.setPlaylist(options.showPlaylist); 51 | } 52 | 53 | build(options) { 54 | this.defaultImage = options.image || ''; 55 | this.callbackGetLyric = options.getLyric; 56 | this.progress = new Progress(); 57 | const buttons = {}; 58 | const image = createElement('div', { 59 | className: 'h5p-image', 60 | }); 61 | const toolbar = createElement('div', { 62 | className: 'h5p-toolbar', 63 | }, [ 64 | buttons.repeat = createElement('i', { 65 | className: 'h5p-button', 66 | on: { 67 | click: this.handleSwitchMode, 68 | }, 69 | }, [createSVGIcon('h5p-repeat')]), 70 | buttons.list = createElement('i', { 71 | className: 'h5p-button', 72 | on: { 73 | click: this.handleToggleList, 74 | }, 75 | }, [createSVGIcon('h5p-list')]), 76 | ]); 77 | const title = createElement('div', { 78 | className: 'h5p-title', 79 | }); 80 | const artist = createElement('div', { 81 | className: 'h5p-artist', 82 | }); 83 | const info = createElement('div', { 84 | className: 'h5p-info', 85 | }, [title, artist]); 86 | const control = createElement('div', { 87 | className: 'h5p-control', 88 | }, [ 89 | createElement('i', { 90 | className: 'h5p-button', 91 | on: { 92 | click: this.handlePlayPrev, 93 | }, 94 | }, [createSVGIcon('h5p-backward')]), 95 | buttons.play = createElement('i', { 96 | className: 'h5p-button', 97 | on: { 98 | click: this.handleTogglePlay, 99 | }, 100 | }, [createSVGIcon('h5p-play')]), 101 | createElement('i', { 102 | className: 'h5p-button', 103 | on: { 104 | click: this.handlePlayNext, 105 | }, 106 | }, [createSVGIcon('h5p-forward')]), 107 | ]); 108 | const progress = createElement('div', { 109 | className: 'h5p-progress-wrap', 110 | }, [this.progress.el]); 111 | const lyric = createElement('div', { 112 | className: 'h5p-lyric', 113 | }); 114 | const playlist = createElement('div', { 115 | className: 'h5p-playlist', 116 | on: { 117 | click: this.handlePlayItem, 118 | }, 119 | }); 120 | const audio = bindEvents(new Audio(), { 121 | ended: this.handlePlayAnother, 122 | timeupdate: this.handleUpdateTime, 123 | play: this.handleStatusChange, 124 | pause: this.handleStatusChange, 125 | }); 126 | this.progress.on('cursor', this.handleCursorChange); 127 | this.audio = audio; 128 | this.el = createElement('div', { 129 | className: 'h5p', 130 | }, [ 131 | image, toolbar, info, control, 132 | progress, lyric, playlist, audio, 133 | ]); 134 | this.els = { 135 | image, buttons, lyric, playlist, title, artist, 136 | }; 137 | this.lyricParser = new LyricParser(); 138 | } 139 | 140 | destroy() { 141 | const { el } = this; 142 | const parent = el.parentNode; 143 | if (parent) parent.removeChild(el); 144 | } 145 | 146 | play(index) { 147 | if (index == null) index = this.current; 148 | let song = this.songs[index]; 149 | if (!song) song = this.songs[index = 0]; 150 | if (song) { 151 | if (this.current !== index) { 152 | const { childNodes } = this.els.playlist; 153 | const last = childNodes[this.current]; 154 | if (last) last.classList.remove(H5P_ACTIVE); 155 | this.current = index; 156 | childNodes[index].classList.add(H5P_ACTIVE); 157 | this.audio.src = song.url; 158 | this.duration = song.duration ? song.duration / 1000 : null; 159 | this.showInfo(song); 160 | this.progress.setCursor(0, this.duration); 161 | } 162 | this.audio.play(); 163 | } 164 | } 165 | 166 | prev() { 167 | return (this.current + this.songs.length - 1) % this.songs.length; 168 | } 169 | 170 | next() { 171 | return (this.current + 1) % this.songs.length; 172 | } 173 | 174 | setSongs(songs) { 175 | this.songs = songs; 176 | const { playlist } = this.els; 177 | empty(playlist); 178 | songs.forEach(({ name }) => { 179 | playlist.appendChild(createElement('div', { 180 | title: name, 181 | textContent: name, 182 | })); 183 | }); 184 | this.current = -1; 185 | this.audio.src = ''; 186 | this.duration = 0; 187 | this.showInfo(this.songs[0]); 188 | } 189 | 190 | showInfo(song) { 191 | this.updateInfo(song); 192 | const { name, artist } = song || {}; 193 | const { els } = this; 194 | els.title.textContent = name || ''; 195 | els.artist.textContent = artist || ''; 196 | } 197 | 198 | updateInfo(item) { 199 | const song = item || this.songs[this.current]; 200 | let { image } = song || {}; 201 | if (typeof image === 'object') image = image[this.theme]; 202 | image = image || this.defaultImage; 203 | const { els } = this; 204 | const imageEl = empty(els.image); 205 | if (image) { 206 | imageEl.appendChild(createElement('img', { 207 | src: image, 208 | })); 209 | } 210 | els.lyric.textContent = ''; 211 | if (song) this.loadLyric(song); 212 | } 213 | 214 | loadLyric(song) { 215 | const { lyricParser } = this; 216 | if (song.lyric == null) { 217 | lyricParser.setLyric(); 218 | const { callbackGetLyric } = this; 219 | if (callbackGetLyric) { 220 | callbackGetLyric({ ...song }, lyric => { 221 | if (song === this.songs[this.current]) { 222 | lyricParser.setLyric(song.lyric = lyric || ''); 223 | } 224 | }); 225 | } 226 | } else { 227 | lyricParser.setLyric(song.lyric); 228 | } 229 | } 230 | 231 | setTheme(name) { 232 | const { themes } = Player; 233 | let index = themes.indexOf(name); 234 | if (index < 0) index = 0; 235 | const oldTheme = this.theme; 236 | this.theme = themes[index]; 237 | if (oldTheme !== this.theme) { 238 | const { classList } = this.el; 239 | classList.remove(`h5p-${oldTheme}`); 240 | classList.add(`h5p-${this.theme}`); 241 | this.updateInfo(); 242 | } 243 | } 244 | 245 | setMode(mode) { 246 | this.mode = MODES.indexOf(mode) < 0 ? MODES[0] : mode; 247 | const icon = MODE_ICONS[this.mode]; 248 | this.els.buttons.repeat.firstChild.replaceWith(createSVGIcon(icon)); 249 | } 250 | 251 | setPlaylist(show) { 252 | const { playlist, buttons } = this.els; 253 | buttons.list.classList.toggle(H5P_ACTIVE, !!show); 254 | playlist.style.display = show ? 'block' : ''; 255 | } 256 | 257 | handleSwitchMode = e => { 258 | prevent(e); 259 | const index = MODES.indexOf(this.mode); 260 | this.setMode(MODES[(index + 1) % MODES.length]); 261 | } 262 | 263 | handleToggleList = e => { 264 | prevent(e); 265 | this.setPlaylist(!this.els.buttons.list.classList.contains(H5P_ACTIVE)); 266 | } 267 | 268 | handleTogglePlay = e => { 269 | prevent(e); 270 | if (this.current < 0) this.play(0); 271 | else if (this.audio.paused) this.audio.play(); 272 | else this.audio.pause(); 273 | } 274 | 275 | handlePlayPrev = e => { 276 | prevent(e); 277 | this.play(this.prev()); 278 | } 279 | 280 | handlePlayNext = e => { 281 | prevent(e); 282 | this.play(this.next()); 283 | } 284 | 285 | handlePlayAnother = () => { 286 | const { mode } = this; 287 | if (mode === 'repeatAll') { 288 | this.handlePlayNext(); 289 | } else if (mode === 'repeatOne') { 290 | this.play(); 291 | } else { 292 | const next = this.next(); 293 | if (next) this.play(next); 294 | } 295 | } 296 | 297 | handleUpdateTime = e => { 298 | const { target } = e; 299 | const { currentTime } = target; 300 | this.duration = target.duration || this.duration; 301 | this.progress.setCursor(currentTime, this.duration); 302 | this.els.lyric.textContent = this.lyricParser.getLyricByTime(currentTime); 303 | } 304 | 305 | handleStatusChange = e => { 306 | const { type } = e; 307 | const isPlaying = type === 'play'; 308 | if (isPlaying) { 309 | setCurrentPlayer(this); 310 | fireEvent({ type, player: this }); 311 | } else if (currentPlayer === this) { 312 | currentPlayer = null; 313 | fireEvent({ type, player: this }); 314 | } 315 | const { play } = this.els.buttons; 316 | play.firstChild.replaceWith(createSVGIcon(isPlaying ? 'h5p-pause' : 'h5p-play')); 317 | this.els.image.classList.toggle('h5p-roll', isPlaying); 318 | } 319 | 320 | handlePlayItem = e => { 321 | prevent(e); 322 | const { childNodes } = this.els.playlist; 323 | for (let i = 0; i < childNodes.length; i += 1) { 324 | const child = childNodes[i]; 325 | if (child === e.target) { 326 | this.play(i); 327 | break; 328 | } 329 | } 330 | } 331 | 332 | handleCursorChange = pos => { 333 | const currentTime = this.duration * pos | 0; 334 | this.audio.currentTime = currentTime; 335 | this.play(); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/lyric.js: -------------------------------------------------------------------------------- 1 | const RE_LYRIC = /^\[([\d:.]+)\]\s*(.*)$/; 2 | 3 | function getTime(str) { 4 | let time = 0; 5 | str.split(':').forEach(part => { 6 | time = time * 60 + (+part); 7 | }); 8 | return time; 9 | } 10 | 11 | export default class LyricParser { 12 | constructor() { 13 | this.reset(); 14 | } 15 | 16 | reset() { 17 | this.data = []; 18 | this.index = 0; 19 | } 20 | 21 | setLyric(lyric) { 22 | this.reset(); 23 | const { data } = this; 24 | (lyric || '').split('\n') 25 | .forEach(line => { 26 | const matches = line.match(RE_LYRIC); 27 | if (matches) data.push([getTime(matches[1]), matches[2]]); 28 | }); 29 | } 30 | 31 | getLyricByTime(time) { 32 | const { data } = this; 33 | let { index } = this; 34 | const last = data[index] || data[index = 0]; 35 | if (last) { 36 | const step = last[0] > time ? -1 : 1; 37 | while (true) { // eslint-disable-line no-constant-condition 38 | const item = data[index]; 39 | const next = data[index + 1]; 40 | if ((!item || item[0] <= time) && (!next || next[0] > time)) break; 41 | index += step; 42 | } 43 | } 44 | const current = data[this.index = index]; 45 | return current ? current[1] : ''; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/progress.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from './events'; 2 | import { prevent, createElement } from './util'; 3 | 4 | export default class Progress extends EventEmitter { 5 | constructor() { 6 | super(); 7 | this.build(); 8 | } 9 | 10 | build() { 11 | const played = createElement('div', { 12 | className: 'h5p-played', 13 | }); 14 | const bar = createElement('div', { 15 | className: 'h5p-bar', 16 | }, [played]); 17 | const cursor = createElement('div', { 18 | className: 'h5p-cursor', 19 | on: { 20 | mousedown: this.handleCursor, 21 | click: prevent, 22 | }, 23 | }); 24 | const time = createElement('div', { 25 | className: 'h5p-time', 26 | }); 27 | const el = createElement('div', { 28 | className: 'h5p-progress', 29 | on: { 30 | click: this.handleCursorChange, 31 | }, 32 | }, [bar, cursor, time]); 33 | this.el = el; 34 | this.els = { 35 | bar, played, cursor, time, 36 | }; 37 | } 38 | 39 | setCursor(currentTime, duration) { 40 | if (!this.cursorData) this.setCursorPos(duration ? currentTime / duration : null); 41 | this.els.time.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; 42 | } 43 | 44 | setCursorPos(pos) { 45 | const { played, cursor } = this.els; 46 | const past = `${(pos || 0) * 100}%`; 47 | played.style.width = past; 48 | cursor.style.left = past; 49 | } 50 | 51 | getPos(e) { 52 | const pos = (e.clientX - this.cursorData.delta) / this.els.bar.offsetWidth; 53 | return Math.max(0, Math.min(1, pos)); 54 | } 55 | 56 | handleCursor = e => { 57 | prevent(e); 58 | this.cursorData = { 59 | delta: e.clientX - this.els.played.offsetWidth, 60 | }; 61 | document.addEventListener('mousemove', this.handleCursorMove, false); 62 | document.addEventListener('mouseup', this.handleCursorEnd, false); 63 | } 64 | 65 | handleCursorMove = e => { 66 | prevent(e); 67 | this.cursorData.moved = true; 68 | this.setCursorPos(this.getPos(e)); 69 | } 70 | 71 | handleCursorChange = e => { 72 | let x; 73 | if ('offsetX' in e) { 74 | x = e.offsetX; 75 | } else { 76 | const rect = e.target.getBoundingClientRect(); 77 | const docEl = document.documentElement; 78 | const win = window; 79 | x = e.pageX - (rect.left + win.pageXOffset - docEl.clientLeft); 80 | } 81 | const pos = x / this.els.bar.offsetWidth; 82 | this.setCursorPos(pos); 83 | this.emit('cursor', pos); 84 | } 85 | 86 | handleCursorEnd = e => { 87 | document.removeEventListener('mousemove', this.handleCursorMove, false); 88 | document.removeEventListener('mouseup', this.handleCursorEnd, false); 89 | const pos = this.getPos(e); 90 | this.cursorData = null; 91 | this.setCursorPos(pos); 92 | this.emit('cursor', pos); 93 | }; 94 | } 95 | 96 | function formatTime(time) { 97 | const minutes = time / 60 | 0; 98 | const seconds = time % 60 | 0; 99 | return `${leftpadNumber(minutes, 2)}:${leftpadNumber(seconds, 2)}`; 100 | } 101 | 102 | function leftpadNumber(num, len) { 103 | const pad = Number.isNaN(num) ? '?' : '0'; 104 | let str; 105 | for (str = `${num}`; str.length < len; str = `${pad}${str}`); 106 | return str; 107 | } 108 | -------------------------------------------------------------------------------- /src/sprite.js: -------------------------------------------------------------------------------- 1 | import svgSprite from './temp/svg-symbols.svg'; 2 | import { createElement } from './util'; 3 | 4 | function initialize() { 5 | const { body } = document; 6 | if (!body) { 7 | document.addEventListener('DOMContentLoaded', initialize); 8 | return; 9 | } 10 | const sprite = createElement('div', { 11 | innerHTML: svgSprite, 12 | }); 13 | sprite.style.display = 'none'; 14 | body.insertBefore(sprite, body.firstChild); 15 | } 16 | 17 | initialize(); 18 | -------------------------------------------------------------------------------- /src/style.less: -------------------------------------------------------------------------------- 1 | @keyframes h5p-roll { 2 | from { 3 | transform: rotate(0deg); 4 | } 5 | to { 6 | transform: rotate(360deg); 7 | } 8 | } 9 | 10 | // common styles 11 | @bar-height: 2px; 12 | @bar-color: silver; 13 | @played-color: brown; 14 | .h5p { 15 | background: #fff; 16 | box-shadow: 0 0 10px gray; 17 | position: relative; 18 | &-button { 19 | cursor: pointer; 20 | &:hover, 21 | &.h5p-active { 22 | color: dodgerblue; 23 | } 24 | svg { 25 | width: 20px; 26 | height: 20px; 27 | fill: currentColor; 28 | } 29 | } 30 | &-image { 31 | position: absolute; 32 | img { 33 | width: 100%; 34 | height: 100%; 35 | } 36 | } 37 | &-title { 38 | font-weight: bold; 39 | } 40 | &-bar { 41 | height: @bar-height; 42 | background: @bar-color; 43 | position: relative; 44 | } 45 | &-played { 46 | position: absolute; 47 | top: 0; 48 | height: @bar-height; 49 | background: @played-color; 50 | } 51 | &-playlist { 52 | display: none; 53 | max-height: 200px; 54 | border-top: 1px dashed gray; 55 | padding: 8px 16px; 56 | overflow-y: auto; 57 | & > div { 58 | white-space: nowrap; 59 | text-overflow: ellipsis; 60 | color: #1a1a1a; 61 | cursor: pointer; 62 | &.h5p-active { 63 | color: dodgerblue; 64 | font-weight: bold; 65 | } 66 | &:hover { 67 | color: orange; 68 | } 69 | } 70 | } 71 | } 72 | 73 | // Theme: normal 74 | .h5p-normal { 75 | border-radius: 3px; 76 | max-width: 400px; 77 | font-size: 16px; 78 | .h5p-image { 79 | width: 130px; 80 | height: 130px; 81 | left: 10px; 82 | top: 10px; 83 | animation: h5p-roll 8s linear infinite; 84 | animation-play-state: paused; 85 | img { 86 | border-radius: 50%; 87 | } 88 | } 89 | .h5p-roll { 90 | animation-play-state: running; 91 | } 92 | .h5p-toolbar { 93 | position: absolute; 94 | top: 10px; 95 | right: 10px; 96 | font-size: 20px; 97 | .h5p-button { 98 | margin-left: 5px; 99 | } 100 | } 101 | .h5p-info { 102 | height: 70px; 103 | padding: 30px 10px 0 150px; 104 | text-align: center; 105 | } 106 | .h5p-control { 107 | padding: 0 10px 20px 150px; 108 | text-align: center; 109 | .h5p-button { 110 | margin: 10px; 111 | font-size: 30px; 112 | } 113 | } 114 | .h5p-artist { 115 | height: 20px; 116 | font-size: 10px; 117 | } 118 | .h5p-lyric { 119 | height: 24px; 120 | text-align: center; 121 | color: brown; 122 | font-size: 12px; 123 | } 124 | @cur-color: white; 125 | @cur-height: 10px; 126 | @cur-width: 10px; 127 | .h5p-progress-wrap { 128 | padding: 5px 10px; 129 | } 130 | .h5p-progress { 131 | position: relative; 132 | padding: (@cur-height - @bar-height) / 2 0; 133 | } 134 | .h5p-cursor { 135 | position: absolute; 136 | width: @cur-width; 137 | height: @cur-height; 138 | top: 0; 139 | margin-left: -@cur-width / 2; 140 | border: 1px solid @bar-color; 141 | border-radius: 50%; 142 | background: @cur-color; 143 | cursor: pointer; 144 | } 145 | .h5p-time { 146 | position: absolute; 147 | top: -20px; 148 | right: 0; 149 | font-size: 10px; 150 | } 151 | } 152 | 153 | // Theme: simple 154 | .h5p-simple { 155 | max-width: 300px; 156 | height: 36px; 157 | border-radius: 2px; 158 | .h5p-image { 159 | width: 34px; 160 | height: 34px; 161 | } 162 | .h5p-toolbar, 163 | .h5p-lyric, 164 | .h5p-time { 165 | display: none; 166 | } 167 | .h5p-info { 168 | position: absolute; 169 | top: 0; 170 | left: 40px; 171 | right: 72px; 172 | line-height: 34px; 173 | font-size: 10px; 174 | white-space: nowrap; 175 | text-overflow: ellipsis; 176 | overflow: hidden; 177 | & > * { 178 | margin: 0 5px; 179 | } 180 | } 181 | .h5p-control { 182 | position: absolute; 183 | top: 0; 184 | right: 5px; 185 | line-height: 34px; 186 | .h5p-button { 187 | display: inline-block; 188 | margin: 0 5px; 189 | font-size: 18px; 190 | } 191 | } 192 | .h5p-title, 193 | .h5p-artist { 194 | display: inline; 195 | } 196 | .h5p-progress-wrap { 197 | position: absolute; 198 | bottom: 0; 199 | width: 100%; 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export function prevent(e) { 2 | if (e && e.preventDefault) { 3 | e.preventDefault(); 4 | e.stopPropagation(); 5 | } 6 | } 7 | 8 | export function createElement(tagName, props, children) { 9 | const el = document.createElement(tagName); 10 | if (props) { 11 | Object.keys(props).forEach(key => { 12 | const value = props[key]; 13 | if (key === 'on') { 14 | bindEvents(el, value); 15 | } else { 16 | el[key] = value; 17 | } 18 | }); 19 | } 20 | if (children) { 21 | children.forEach(child => { 22 | el.appendChild(child); 23 | }); 24 | } 25 | return el; 26 | } 27 | 28 | export function bindEvents(el, events) { 29 | if (events) { 30 | Object.keys(events).forEach(type => { 31 | const handle = events[type]; 32 | if (handle) el.addEventListener(type, handle); 33 | }); 34 | } 35 | return el; 36 | } 37 | 38 | export function empty(el) { 39 | el.innerHTML = ''; 40 | return el; 41 | } 42 | 43 | const NS_SVG = 'http://www.w3.org/2000/svg'; 44 | const NS_XLINK = 'http://www.w3.org/1999/xlink'; 45 | 46 | export function createSVGElement(tagName, children) { 47 | const el = document.createElementNS(NS_SVG, tagName); 48 | if (children) { 49 | children.forEach(child => { 50 | el.appendChild(child); 51 | }); 52 | } 53 | return el; 54 | } 55 | 56 | export function createSVGIcon(name) { 57 | const use = createSVGElement('use'); 58 | use.setAttributeNS(NS_XLINK, 'href', `#${name}`); 59 | return createSVGElement('svg', [use]); 60 | } 61 | --------------------------------------------------------------------------------