diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..0f09989 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..cb94a48 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,296 @@ +module.exports = { + root: true, + plugins: [ + 'jsdoc', // + 'promise', + 'security', + 'import', + '@typescript-eslint', + ], + extends: ['eslint:recommended', 'airbnb-base'], + env: { + browser: true, + es6: true, + }, + settings: { + 'import/resolver': { + node: { + extensions: ['.js', '.ts'], + }, + }, + jsdoc: { + preferredTypes: { + Array: 'Array', + 'Array.': 'Array', + 'Array<>': '[]', + 'Array.<>': '[]', + 'Promise.<>': 'Promise<>', + }, + }, + }, + rules: { + 'prettier/prettier': [ + 'error', + {}, + { + usePrettierrc: true, + }, + ], + curly: ['error', 'all'], + 'callback-return': ['error', ['callback', 'cb', 'next', 'done']], + 'class-methods-use-this': 'off', + 'consistent-return': 'off', + 'handle-callback-err': ['error', '^.*err'], + 'new-cap': 'off', + 'no-console': 'error', + 'no-else-return': 'error', + 'no-eq-null': 'off', + 'no-global-assign': 'error', + 'no-loop-func': 'off', + 'no-lone-blocks': 'error', + 'no-negated-condition': 'error', + 'no-shadow': 'error', + 'no-template-curly-in-string': 'error', + 'no-undef': 'error', + 'no-underscore-dangle': 'off', + 'no-unsafe-negation': 'error', + 'no-use-before-define': ['error', 'nofunc'], + 'no-useless-rename': 'error', + 'padding-line-between-statements': [ + 'error', + { + blankLine: 'always', + prev: [ + 'directive', // + 'block', + 'block-like', + 'multiline-block-like', + 'cjs-export', + 'cjs-import', + 'class', + 'export', + 'import', + 'if', + ], + next: '*', + }, + { blankLine: 'never', prev: 'directive', next: 'directive' }, + { blankLine: 'any', prev: '*', next: ['if', 'for', 'cjs-import', 'import'] }, + { blankLine: 'any', prev: ['export', 'import'], next: ['export', 'import'] }, + { blankLine: 'always', prev: '*', next: ['try', 'function', 'switch'] }, + { blankLine: 'always', prev: 'if', next: 'if' }, + { blankLine: 'never', prev: ['return', 'throw'], next: '*' }, + ], + strict: ['error', 'safe'], + 'no-new': 'off', + 'no-empty': 'error', + 'no-empty-function': 'error', + 'valid-jsdoc': 'off', + yoda: 'error', + + 'import/extensions': ['error', 'never'], + 'import/no-unresolved': 'off', + 'import/order': [ + 'error', + { + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + + 'jsdoc/check-alignment': 'error', + 'jsdoc/check-indentation': 'off', + 'jsdoc/check-param-names': 'off', + 'jsdoc/check-tag-names': 'error', + 'jsdoc/check-types': 'error', + 'jsdoc/newline-after-description': 'off', + 'jsdoc/no-undefined-types': 'off', + 'jsdoc/require-description': 'off', + 'jsdoc/require-description-complete-sentence': 'off', + 'jsdoc/require-example': 'off', + 'jsdoc/require-hyphen-before-param-description': 'error', + 'jsdoc/require-param': 'error', + 'jsdoc/require-param-description': 'off', + 'jsdoc/require-param-name': 'error', + 'jsdoc/require-param-type': 'error', + 'jsdoc/require-returns-description': 'off', + 'jsdoc/require-returns-type': 'error', + 'jsdoc/valid-types': 'error', + + 'promise/always-return': 'error', + 'promise/always-catch': 'off', + 'promise/catch-or-return': ['error', { allowThen: true }], + 'promise/no-native': 'off', + 'promise/param-names': 'error', + + 'security/detect-buffer-noassert': 'error', + 'security/detect-child-process': 'error', + 'security/detect-disable-mustache-escape': 'error', + 'security/detect-eval-with-expression': 'error', + 'security/detect-new-buffer': 'error', + 'security/detect-no-csrf-before-method-override': 'error', + 'security/detect-non-literal-fs-filename': 'error', + 'security/detect-non-literal-regexp': 'error', + 'security/detect-non-literal-require': 'off', + 'security/detect-object-injection': 'off', + 'security/detect-possible-timing-attacks': 'error', + 'security/detect-pseudoRandomBytes': 'error', + 'security/detect-unsafe-regex': 'error', + + // Override airbnb + eqeqeq: ['error', 'smart'], + 'func-names': 'error', + 'id-length': ['error', { exceptions: ['_', '$', 'e', 'i', 'j', 'k', 'q', 'x', 'y'] }], + 'no-param-reassign': 'off', // Work toward enforcing this rule + radix: 'off', + 'spaced-comment': 'off', + 'max-len': 'off', + 'no-continue': 'off', + 'no-plusplus': 'off', + 'no-prototype-builtins': 'off', + 'no-restricted-syntax': ['error', 'DebuggerStatement', 'LabeledStatement', 'WithStatement'], + 'no-restricted-properties': [ + 'error', + { + object: 'arguments', + property: 'callee', + message: 'arguments.callee is deprecated', + }, + { + property: '__defineGetter__', + message: 'Please use Object.defineProperty instead.', + }, + { + property: '__defineSetter__', + message: 'Please use Object.defineProperty instead.', + }, + ], + 'no-useless-escape': 'off', + 'object-shorthand': [ + 'error', + 'always', + { + ignoreConstructors: false, + avoidQuotes: true, + avoidExplicitReturnArrows: true, + }, + ], + // 'prefer-arrow-callback': ['error', { 'allowNamedFunctions': true }], + 'prefer-spread': 'error', + 'prefer-destructuring': 'off', + }, + overrides: [ + { + files: ['*.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + }, + extends: [ + 'eslint:recommended', + 'airbnb-typescript/base', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'prettier', + 'prettier/@typescript-eslint', + 'plugin:prettier/recommended', + ], + rules: { + 'class-methods-use-this': 'off', + indent: 'off', + 'max-classes-per-file': 'off', + 'max-len': 'off', + 'no-dupe-class-members': 'off', + 'no-extra-semi': 'off', + 'no-new': 'off', + 'no-param-reassign': 'off', + 'no-underscore-dangle': 'off', + 'no-useless-constructor': 'off', + 'no-unused-expressions': 'error', + 'no-restricted-syntax': ['error', 'DebuggerStatement', 'LabeledStatement', 'WithStatement'], + 'no-use-before-define': 'off', + 'no-shadow': 'off', + 'no-void': 'off', + + 'import/prefer-default-export': 'off', + 'import/no-cycle': 'off', + 'import/no-extraneous-dependencies': 'off', + 'import/extensions': ['error', 'never'], + 'import/order': [ + 'error', + { + 'newlines-between': 'always', + alphabetize: { order: 'asc', caseInsensitive: true }, + }, + ], + + '@typescript-eslint/array-type': ['error', { default: 'array' }], + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/adjacent-overload-signatures': 'error', + '@typescript-eslint/consistent-type-assertions': 'error', + '@typescript-eslint/consistent-type-definitions': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-extraneous-class': 'error', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/explicit-function-return-type': 'error', + '@typescript-eslint/explicit-member-accessibility': ['error'], + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'enumMember', + format: ['camelCase', 'PascalCase', 'UPPER_CASE'], + }, + ], + '@typescript-eslint/member-ordering': [ + 'error', + { + default: [ + // Index signature + 'signature', + // Fields + 'private-field', + 'public-field', + 'protected-field', + // Constructors + 'public-constructor', + 'protected-constructor', + 'private-constructor', + // Methods + 'public-method', + 'protected-method', + 'private-method', + ], + }, + ], + '@typescript-eslint/no-array-constructor': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-extra-semi': 'error', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/no-for-in-array': 'error', + '@typescript-eslint/no-misused-promises': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-parameter-properties': ['error', { allows: ['readonly'] }], + '@typescript-eslint/no-require-imports': 'error', + '@typescript-eslint/no-this-alias': 'error', + '@typescript-eslint/no-throw-literal': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-unused-expressions': 'error', + '@typescript-eslint/no-useless-constructor': 'error', + '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/no-shadow': 'error', + '@typescript-eslint/prefer-for-of': 'error', + '@typescript-eslint/prefer-includes': 'error', + '@typescript-eslint/prefer-regexp-exec': 'warn', + '@typescript-eslint/prefer-string-starts-ends-with': 'error', + '@typescript-eslint/promise-function-async': 'off', + '@typescript-eslint/require-await': 'error', + '@typescript-eslint/restrict-plus-operands': 'error', + '@typescript-eslint/unbound-method': 'error', + '@typescript-eslint/unified-signatures': 'error', + }, + }, + ], +}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3455456 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: jgeurts +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.gitignore b/.gitignore index ae4be6f..22421ad 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ nbproject .eslintcache package-lock.json + +index.js +index.js.map +index.d.ts diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..a9cb124 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname $0)/_/husky.sh" + +echo 'NOTE: If node is not found, you may need to run brew link for your specific node version' +/usr/local/bin/node node_modules/.bin/lint-staged diff --git a/.npmignore b/.npmignore index ce927b2..66f0a19 100644 --- a/.npmignore +++ b/.npmignore @@ -1,8 +1,11 @@ .git .gitignore .editorconfig +.eslintrc.js .npmrc +.prettierrc.js .github +.husky node_modules npm-debug.log @@ -29,3 +32,5 @@ dump.rdb Dockerfile Makefile + +index.ts diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4fef437 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +package-lock=false diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..cd1ec1d --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,34 @@ +'use strict'; + +module.exports = { + arrowParens: 'always', + bracketSpacing: true, + printWidth: 200, + quoteProps: 'as-needed', + semi: true, + singleQuote: true, + useTabs: false, + tabWidth: 2, + trailingComma: 'all', + + overrides: [ + { + files: '*.js', + options: { + parser: 'babel', + }, + }, + { + files: '*.json', + options: { + parser: 'json', + }, + }, + { + files: '*.ts', + options: { + parser: 'typescript', + }, + }, + ], +}; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..419dfb3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +### 2.2.3 + * Add option to specify `mode` for `fetch()` operations + +### 2.2.2 + * Allow currentTrack to be undefined + +### 2.2.1 + * Use `globalThis` instead of `window` to access `AudioContext` + +### 2.2.0 + * Remove `tracks` from Queue constructor + * Expose Queue.tracks as public + * Transpile as commonjs module + +### 2.1.0 + Update dependencies and code style + + * Format with prettier + * Order methods and properties based on accessor type + * Add explicit return types to functions + * Remove deprecated seekToEnd() function + +### 2.0.0 + * Convert to typescript + * Fix a handful of bugs as part of the typescript conversion + +### 1.0.0 + + * Initial release - [Original source](https://github.com/RelistenNet/gapless.js) diff --git a/LICENSE b/LICENSE index 98d350f..6b65152 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2017 Daniel Saewitz +Copyright (c) 2017 Daniel Saewitz, Jim Geurts Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 119542e..ce3f4ea 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ gapless.js is a library for gapless audio playback. It is not intended to be a f In short, it takes an array of audio tracks and utilizes HTML5 audio and the web audio API to enable gapless playback of individual tracks. -I will expand this README with more details in time. If you're interested in this library, don't hesitate to leave me a note with any questions. +This library targets ES2018, which should be supported by all evergreen browsers: Safari v12+, Chrome 75+, Firefox 58+. You can see a sample of the library in use currently at which is the not-yet-released beta of the next version of diff --git a/index.js b/index.js deleted file mode 100644 index 29bae5b..0000000 --- a/index.js +++ /dev/null @@ -1,520 +0,0 @@ -(function(factory) { - // Establish the root object, `window` (`self`) in the browser, or `global` on the server. - // We use `self` instead of `window` for `WebWorker` support. - var root = (typeof self == 'object' && self.self === self && self) || - (typeof global == 'object' && global.global === global && global); - - // Node.js, CommonJS, or ES6 - if (typeof module === "object" && typeof module.exports === "object") { - module.exports = factory(root, exports); - // Finally, as a browser global. - } else { - root.Gapless = factory(root, {}); - } -})(function(root, Gapless) { - const PRELOAD_NUM_TRACKS = 2; - - const isBrowser = typeof window !== 'undefined'; - const audioContext = isBrowser ? new (window.AudioContext || window.webkitAudioContext)() : null; - - const GaplessPlaybackType = { - HTML5: 'HTML5', - WEBAUDIO: 'WEBAUDIO' - }; - - const GaplessPlaybackLoadingState = { - NONE: 'NONE', - LOADING: 'LOADING', - LOADED: 'LOADED' - }; - - class Queue { - constructor(props = {}) { - const { - tracks = [], - onProgress, - onEnded, - onPlayNextTrack, - onPlayPreviousTrack, - onStartNewTrack, - webAudioIsDisabled = false - } = props; - - this.props = { - onProgress, - onEnded, - onPlayNextTrack, - onPlayPreviousTrack, - onStartNewTrack - }; - - this.state = { - volume: 1, - currentTrackIdx: 0, - webAudioIsDisabled - }; - - this.Track = Track; - - this.tracks = tracks.map((trackUrl, idx) => - new Track({ - trackUrl, - idx, - queue: this - }) - ); - } - - addTrack({ trackUrl, metadata = {} }) { - this.tracks.push( - new Track({ - trackUrl, - metadata, - idx: this.tracks.length, - queue: this - }) - ); - } - - removeTrack(track) { - const index = this.tracks.indexOf(track); - return this.tracks.splice(index, 1); - } - - togglePlayPause() { - if (this.currentTrack) this.currentTrack.togglePlayPause(); - } - - play() { - if (this.currentTrack) this.currentTrack.play(); - } - - pause() { - if (this.currentTrack) this.currentTrack.pause(); - } - - playPrevious() { - this.resetCurrentTrack(); - - if (--this.state.currentTrackIdx < 0) this.state.currentTrackIdx = 0; - - this.resetCurrentTrack(); - - this.play(); - - if (this.props.onStartNewTrack) this.props.onStartNewTrack(this.currentTrack); - if (this.props.onPlayPreviousTrack) this.props.onPlayPreviousTrack(this.currentTrack); - } - - playNext() { - this.resetCurrentTrack(); - - this.state.currentTrackIdx++; - - this.resetCurrentTrack(); - - this.play(); - - if (this.props.onStartNewTrack) this.props.onStartNewTrack(this.currentTrack); - if (this.props.onPlayNextTrack) this.props.onPlayNextTrack(this.currentTrack); - } - - resetCurrentTrack() { - if (this.currentTrack) { - this.currentTrack.seek(0) - this.currentTrack.pause(); - } - } - - pauseAll() { - Object.values(this.tracks).map(track => { - track.pause(); - }); - } - - gotoTrack(idx, playImmediately = false) { - this.pauseAll(); - this.state.currentTrackIdx = idx; - - this.resetCurrentTrack(); - - if (playImmediately) { - this.play(); - if (this.props.onStartNewTrack) this.props.onStartNewTrack(this.currentTrack); - } - } - - loadTrack(idx, loadHTML5) { - // only preload if song is within the next 2 - if (this.state.currentTrackIdx + PRELOAD_NUM_TRACKS <= idx) return; - const track = this.tracks[idx]; - - if (track) track.preload(loadHTML5); - } - - setProps(obj = {}) { - this.props = Object.assign(this.props, obj); - } - - onEnded() { - if (this.props.onEnded) this.props.onEnded(); - } - - onProgress(track) { - if (this.props.onProgress) this.props.onProgress(track); - } - - get currentTrack() { - return this.tracks[this.state.currentTrackIdx]; - } - - get nextTrack() { - return this.tracks[this.state.currentTrackIdx + 1]; - } - - disableWebAudio() { - this.state.webAudioIsDisabled = true; - } - - setVolume(nextVolume) { - if (nextVolume < 0) nextVolume = 0; - else if (nextVolume > 1) nextVolume = 1; - - this.state.volume = nextVolume; - - this.tracks.map(track => track.setVolume(nextVolume)); - } - } - - class Track { - constructor({ trackUrl, queue, idx, metadata }) { - // playback type state - this.playbackType = GaplessPlaybackType.HTML5; - this.webAudioLoadingState = GaplessPlaybackLoadingState.NONE; - this.loadedHEAD = false; - - // basic inputs from Queue - this.idx = idx; - this.queue = queue; - this.trackUrl = trackUrl; - this.metadata = metadata; - - this.onEnded = this.onEnded.bind(this); - this.onProgress = this.onProgress.bind(this); - - // HTML5 Audio - this.audio = new Audio(); - this.audio.onerror = this.audioOnError; - this.audio.onended = this.onEnded; - this.audio.controls = false; - this.audio.volume = queue.state.volume; - this.audio.preload = 'none'; - this.audio.src = trackUrl; - // this.audio.onprogress = () => this.debug(this.idx, this.audio.buffered) - - if (queue.state.webAudioIsDisabled) return; - - // WebAudio - this.audioContext = audioContext; - this.gainNode = this.audioContext.createGain(); - this.gainNode.gain.value = queue.state.volume; - this.webAudioStartedPlayingAt = 0; - this.webAudioPausedDuration = 0; - this.webAudioPausedAt = 0; - this.audioBuffer = null; - - this.bufferSourceNode = this.audioContext.createBufferSource(); - this.bufferSourceNode.onended = this.onEnded; - } - - // private functions - loadHEAD(cb) { - if (this.loadedHEAD) return cb(); - - const options = { - method: 'HEAD' - }; - - fetch(this.trackUrl, options) - .then(res => { - if (res.redirected) { - this.trackUrl = res.url; - } - - this.loadedHEAD = true; - - cb(); - }) - } - - loadBuffer(cb) { - if (this.webAudioLoadingState !== GaplessPlaybackLoadingState.NONE) return; - - this.webAudioLoadingState = GaplessPlaybackLoadingState.LOADING; - - fetch(this.trackUrl) - .then(res => res.arrayBuffer()) - .then(res => - this.audioContext.decodeAudioData(res, buffer => { - this.debug('finished downloading track'); - - this.webAudioLoadingState = GaplessPlaybackLoadingState.LOADED; - - this.bufferSourceNode.buffer = this.audioBuffer = buffer; - this.bufferSourceNode.connect(this.gainNode); - - // try to preload next track - this.queue.loadTrack(this.idx + 1); - - // if we loaded the active track, switch to web audio - if (this.isActiveTrack) this.switchToWebAudio(); - - cb && cb(buffer); - }) - ) - .catch(e => this.debug('caught fetch error', e)); - } - - switchToWebAudio() { - // if we've switched tracks, don't switch to web audio - if (!this.isActiveTrack) return; - - this.debug('switch to web audio', this.currentTime, this.isPaused, this.audio.duration - this.audioBuffer.duration); - - // if currentTime === 0, this is a new track, so play it - // otherwise we're hitting this mid-track which may - // happen in the middle of a paused track - this.bufferSourceNode.playbackRate.value = this.currentTime !== 0 && this.isPaused ? 0 : 1; - - this.connectGainNode(); - - this.webAudioStartedPlayingAt = this.audioContext.currentTime - this.currentTime; - - // slight blip, could be improved - this.bufferSourceNode.start(0, this.currentTime); - this.audio.pause(); - - this.playbackType = GaplessPlaybackType.WEBAUDIO; - } - - // public-ish functions - pause() { - if (this.isUsingWebAudio) { - if (this.bufferSourceNode.playbackRate.value === 0) return; - this.webAudioPausedAt = this.audioContext.currentTime; - this.bufferSourceNode.playbackRate.value = 0; - this.gainNode.disconnect(this.audioContext.destination); - } - else { - this.audio.pause(); - } - } - - play() { - this.debug('play'); - if (this.audioBuffer) { - // if we've already set up the buffer just set playbackRate to 1 - if (this.isUsingWebAudio) { - if (this.bufferSourceNode.playbackRate.value === 1) return; - - if (this.webAudioPausedAt) { - this.webAudioPausedDuration += this.audioContext.currentTime - this.webAudioPausedAt; - } - - // use seek to avoid bug where track wouldn't play properly - // if paused for longer than length of track - // TODO: fix bug -- must be related to bufferSourceNode - this.seek(this.currentTime); - // was paused, now force play - this.connectGainNode(); - this.bufferSourceNode.playbackRate.value = 1; - - this.webAudioPausedAt = 0; - } - // otherwise set the bufferSourceNode buffer and switch to WebAudio - else { - this.switchToWebAudio(); - } - - // Try to preload the next track - this.queue.loadTrack(this.idx + 1); - } - else { - this.audio.preload = 'auto'; - this.audio.play(); - if (!this.queue.state.webAudioIsDisabled) this.loadHEAD(() => this.loadBuffer()); - } - - this.onProgress(); - } - - togglePlayPause() { - this.isPaused ? this.play() : this.pause(); - } - - preload(HTML5) { - this.debug('preload', HTML5); - if (HTML5 && this.audio.preload !== 'auto') { - this.audio.preload = 'auto'; - } - else if (!this.audioBuffer && !this.queue.state.webAudioIsDisabled) { - this.loadHEAD(() => this.loadBuffer()); - } - } - - // TODO: add checks for to > duration or null or negative (duration - to) - seek(to = 0) { - if (this.isUsingWebAudio) { - this.seekBufferSourceNode(to); - } - else { - this.audio.currentTime = to; - } - - this.onProgress(); - } - - seekBufferSourceNode(to) { - const wasPaused = this.isPaused; - this.bufferSourceNode.onended = null; - this.bufferSourceNode.stop(); - - this.bufferSourceNode = this.audioContext.createBufferSource(); - - this.bufferSourceNode.buffer = this.audioBuffer; - this.bufferSourceNode.connect(this.gainNode); - this.bufferSourceNode.onended = this.onEnded; - - this.webAudioStartedPlayingAt = this.audioContext.currentTime - to; - this.webAudioPausedDuration = 0; - - this.bufferSourceNode.start(0, to); - if (wasPaused) { - this.connectGainNode(); - this.pause(); - } - } - - connectGainNode() { - this.gainNode.connect(this.audioContext.destination); - } - - // basic event handlers - audioOnError(e) { - this.debug('audioOnError', e); - } - - onEnded() { - this.debug('onEnded'); - this.queue.playNext(); - this.queue.onEnded(); - } - - onProgress() { - if (!this.isActiveTrack) return; - - const isWithinLastTwentyFiveSeconds = (this.duration - this.currentTime) <= 25; - const nextTrack = this.queue.nextTrack; - - // if in last 25 seconds and next track hasn't loaded yet - // start loading next track's HTML5 - if (isWithinLastTwentyFiveSeconds && nextTrack && !nextTrack.isLoaded) { - this.queue.loadTrack(this.idx + 1, true); - } - - this.queue.onProgress(this); - - // if we're paused, we still want to send one final onProgress call - // and then bow out, hence this being at the end of the function - if (this.isPaused) return; - - // this.debug(this.currentTime, this.duration); - window.requestAnimationFrame(this.onProgress); - // setTimeout(this.onProgress, 33.33); // 30fps - } - - setVolume(nextVolume) { - this.audio.volume = nextVolume; - this.gainNode.gain.value = nextVolume; - } - - // getter helpers - get isUsingWebAudio() { - return this.playbackType === GaplessPlaybackType.WEBAUDIO; - } - - get isPaused() { - if (this.isUsingWebAudio) { - return this.bufferSourceNode.playbackRate.value === 0; - } - else { - return this.audio.paused; - } - } - - get currentTime() { - if (this.isUsingWebAudio) { - return this.audioContext.currentTime - this.webAudioStartedPlayingAt - this.webAudioPausedDuration; - } - else { - return this.audio.currentTime; - } - } - - get duration() { - if (this.isUsingWebAudio) { - return this.audioBuffer.duration; - } - else { - return this.audio.duration; - } - } - - get isActiveTrack() { - return this.queue.currentTrack === this; - } - - get isLoaded() { - return this.webAudioLoadingState === GaplessPlaybackLoadingState.LOADED; - } - - get state() { - return { - playbackType: this.playbackType, - webAudioLoadingState: this.webAudioLoadingState - }; - } - - get completeState() { - return { - playbackType: this.playbackType, - webAudioLoadingState: this.webAudioLoadingState, - isPaused: this.isPaused, - currentTime: this.currentTime, - duration: this.duration, - idx: this.idx - }; - } - - // debug helper - debug(first, ...args) { - console.log(`${this.idx}:${first}`, ...args, this.state); - } - - // just a helper to quick jump to the end of a track for testing - seekToEnd() { - if (this.isUsingWebAudio) { - this.seekBufferSourceNode(this.audioBuffer.duration - 6); - } - else { - this.audio.currentTime = this.audio.duration - 6; - } - } - - } - - Gapless.Queue = Queue; - Gapless.Track = Track; - - return Gapless; -}); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..88bcca6 --- /dev/null +++ b/index.ts @@ -0,0 +1,630 @@ +enum PlaybackType { + html5, + webaudio, +} + +enum PlaybackLoadingState { + none, + loading, + loaded, +} + +export interface QueueOptions { + onProgress?: () => void; + onEnded?: () => void; + onPlayNextTrack?: () => void; + onPlayPreviousTrack?: () => void; + onStartNewTrack?: () => void; + webAudioIsDisabled?: boolean; + fetchMode?: 'cors' | 'no-cors' | 'same-origin'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + debug?: ((message: string, ...optionalParams: any[]) => void) | null; + numberOfTracksToPreload?: number; +} + +interface QueueState { + volume: number; + currentTrackIndex: number; + webAudioIsDisabled: boolean; +} + +interface QueueProps { + onProgress?: (track: Track) => void; + onEnded?: () => void; + onPlayNextTrack?: (track: Track) => void; + onPlayPreviousTrack?: (track: Track) => void; + onStartNewTrack?: (track: Track) => void; +} + +interface WithWebkitAudioContext { + webkitAudioContext: AudioContext; +} + +const AudioContext = globalThis.AudioContext || ((globalThis as unknown) as WithWebkitAudioContext).webkitAudioContext; + +export class Queue { + private props: QueueProps; + + private numberOfTracksToPreload: number; + + public readonly tracks: Track[] = []; + + public state: QueueState; + + public readonly fetchMode?: 'cors' | 'no-cors' | 'same-origin'; + + public constructor({ onProgress, onEnded, onPlayNextTrack, onPlayPreviousTrack, onStartNewTrack, webAudioIsDisabled = false, numberOfTracksToPreload = 2, fetchMode = 'cors' }: QueueOptions = {}) { + this.props = { + onProgress, + onEnded, + onPlayNextTrack, + onPlayPreviousTrack, + onStartNewTrack, + }; + this.fetchMode = fetchMode; + + this.numberOfTracksToPreload = numberOfTracksToPreload; + + this.state = { + volume: 1, + currentTrackIndex: 0, + webAudioIsDisabled, + }; + } + + public addTrack({ trackUrl, metadata = {} as TTrackMetadata }: { trackUrl: string; metadata: TTrackMetadata }): void { + this.tracks.push( + // eslint-disable-next-line @typescript-eslint/no-use-before-define + new Track({ + trackUrl, + metadata, + index: this.tracks.length, + queue: this, + }), + ); + } + + public removeTrack(track: Track): void { + const index = this.tracks.indexOf(track); + this.tracks.splice(index, 1); + } + + public async togglePlayPause(): Promise { + if (this.currentTrack) { + await this.currentTrack.togglePlayPause(); + } + } + + public async play(): Promise { + if (this.currentTrack) { + await this.currentTrack.play(); + } + } + + public pause(): void { + if (this.currentTrack) { + this.currentTrack.pause(); + } + } + + public async playPrevious(): Promise { + this.resetCurrentTrack(); + + this.state.currentTrackIndex = Math.max(this.state.currentTrackIndex - 1, 0); + + this.resetCurrentTrack(); + + if (this.currentTrack) { + await this.play(); + + if (this.props.onStartNewTrack) { + this.props.onStartNewTrack(this.currentTrack); + } + + if (this.props.onPlayPreviousTrack) { + this.props.onPlayPreviousTrack(this.currentTrack); + } + } + } + + public async playNext(): Promise { + this.resetCurrentTrack(); + + this.state.currentTrackIndex += 1; + + this.resetCurrentTrack(); + + if (this.currentTrack) { + await this.play(); + + if (this.props.onStartNewTrack) { + this.props.onStartNewTrack(this.currentTrack); + } + + if (this.props.onPlayNextTrack) { + this.props.onPlayNextTrack(this.currentTrack); + } + } + } + + public resetCurrentTrack(): void { + if (this.currentTrack) { + this.currentTrack.seek(0); + this.currentTrack.pause(); + } + } + + public pauseAll(): void { + for (const track of this.tracks) { + track.pause(); + } + } + + public async gotoTrack(trackIndex: number, playImmediately = false): Promise { + this.pauseAll(); + this.state.currentTrackIndex = trackIndex; + + this.resetCurrentTrack(); + + if (playImmediately && this.currentTrack) { + await this.play(); + + if (this.props.onStartNewTrack) { + this.props.onStartNewTrack(this.currentTrack); + } + } + } + + public loadTrack(trackIndex: number, useHtmlAudioPreloading = false): void { + // only preload if song is within the next 2 + if (this.state.currentTrackIndex + this.numberOfTracksToPreload <= trackIndex) { + return; + } + + const track = this.tracks[trackIndex]; + + if (track) { + track.preload(useHtmlAudioPreloading); + } + } + + // Internal - Used by the track to notify when it has ended + public notifyTrackEnded(): void { + if (this.props.onEnded) { + this.props.onEnded(); + } + } + + // Internal - Used by the track to notify when progress has updated + public notifyTrackProgressUpdated(): void { + if (this.props.onProgress && this.currentTrack) { + this.props.onProgress(this.currentTrack); + } + } + + public get currentTrack(): Track | undefined { + if (this.state.currentTrackIndex < this.tracks.length) { + return this.tracks[this.state.currentTrackIndex]; + } + + return undefined; + } + + public get nextTrack(): Track { + return this.tracks[this.state.currentTrackIndex + 1]; + } + + public disableWebAudio(): void { + this.state.webAudioIsDisabled = true; + } + + public setVolume(volume: number): void { + if (volume < 0) { + volume = 0; + } else if (volume > 1) { + volume = 1; + } + + this.state.volume = volume; + + for (const track of this.tracks) { + track.setVolume(volume); + } + } +} + +interface TrackOptions { + trackUrl: string; + queue: Queue; + index: number; + metadata: TTrackMetadata; +} + +interface LimitedTrackState { + playbackType: PlaybackType; + webAudioLoadingState: PlaybackLoadingState; +} + +interface TrackState extends LimitedTrackState { + isPaused: boolean; + currentTime: number; + duration: number; + index: number; +} + +export class Track { + private playbackType: PlaybackType; + + private webAudioLoadingState: PlaybackLoadingState; + + private loadedHead: boolean; + + private queue: Queue; + + private audio: HTMLAudioElement; + + private audioContext: AudioContext; + + private gainNode: GainNode; + + private webAudioStartedPlayingAt: number; + + private webAudioPausedAt: number; + + private webAudioPausedDuration: number; + + private audioBuffer: AudioBuffer | null; + + private bufferSourceNode: AudioBufferSourceNode; + + public metadata: TTrackMetadata; + + public index: number; + + public trackUrl: string; + + public constructor({ trackUrl, queue, index, metadata }: TrackOptions) { + // playback type state + this.playbackType = PlaybackType.html5; + this.webAudioLoadingState = PlaybackLoadingState.none; + this.loadedHead = false; + + // basic inputs from Queue + this.index = index; + this.queue = queue; + this.trackUrl = trackUrl; + this.metadata = metadata; + + // this.onEnded = this.onEnded.bind(this); + // this.onProgress = this.onProgress.bind(this); + + // HTML5 Audio + this.audio = new Audio(); + this.audio.onerror = (e: Event | string): void => { + this.debug('audioOnError', e); + }; + + this.audio.onended = (): void => { + this.notifyTrackEnd(); + }; + this.audio.controls = false; + this.audio.volume = this.queue.state.volume; + this.audio.preload = 'none'; + this.audio.src = trackUrl; + // this.audio.onprogress = () => this.debug(this.index, this.audio.buffered) + + // WebAudio + this.audioContext = new AudioContext(); + this.gainNode = this.audioContext.createGain(); + this.gainNode.gain.value = this.queue.state.volume; + this.webAudioStartedPlayingAt = 0; + this.webAudioPausedDuration = 0; + this.webAudioPausedAt = 0; + this.audioBuffer = null; + + this.bufferSourceNode = this.audioContext.createBufferSource(); + this.bufferSourceNode.onended = (): void => { + this.notifyTrackEnd(); + }; + } + + public pause(): void { + this.debug('pause'); + if (this.isUsingWebAudio) { + if (this.bufferSourceNode.playbackRate.value === 0) { + return; + } + + this.webAudioPausedAt = this.audioContext.currentTime; + this.bufferSourceNode.playbackRate.value = 0; + this.gainNode.disconnect(this.audioContext.destination); + } else { + this.audio.pause(); + } + } + + public async play(): Promise { + this.debug('play'); + if (this.audioBuffer) { + // if we've already set up the buffer just set playbackRate to 1 + if (this.isUsingWebAudio) { + if (this.bufferSourceNode.playbackRate.value === 1) { + return; + } + + if (this.webAudioPausedAt) { + this.webAudioPausedDuration += this.audioContext.currentTime - this.webAudioPausedAt; + } + + // use seek to avoid bug where track wouldn't play properly + // if paused for longer than length of track + // TODO: fix bug -- must be related to bufferSourceNode + this.seek(this.currentTime); + // was paused, now force play + this.connectGainNode(); + this.bufferSourceNode.playbackRate.value = 1; + + this.webAudioPausedAt = 0; + } else { + // otherwise set the bufferSourceNode buffer and switch to WebAudio + this.switchToWebAudio(); + } + + // Try to preload the next track + this.queue.loadTrack(this.index + 1); + } else { + this.audio.preload = 'auto'; + await this.audio.play(); + if (!this.queue.state.webAudioIsDisabled) { + // Fire and forget + this.loadHEAD() + .then(() => { + void this.loadBuffer(); + return true; + }) + .catch(() => undefined); + } + } + + this.onProgress(); + } + + public async togglePlayPause(): Promise { + if (this.isPaused) { + await this.play(); + } else { + this.pause(); + } + } + + public preload(useHtmlAudioPreloading = false): void { + this.debug('preload', useHtmlAudioPreloading); + if (useHtmlAudioPreloading) { + this.audio.preload = 'auto'; + } else if (!this.audioBuffer && !this.queue.state.webAudioIsDisabled) { + // Fire and forget + this.loadHEAD() + .then(() => { + void this.loadBuffer(); + return true; + }) + .catch(() => undefined); + } + } + + // TODO: add checks for to > duration or null or negative (duration - to) + public seek(to = 0): void { + if (this.isUsingWebAudio) { + this.seekBufferSourceNode(to); + } else { + this.audio.currentTime = to; + } + + this.onProgress(); + } + + public connectGainNode(): void { + this.gainNode.connect(this.audioContext.destination); + } + + public setVolume(volume: number): void { + this.audio.volume = volume; + if (this.gainNode) { + this.gainNode.gain.value = volume; + } + } + + // getter helpers + public get isUsingWebAudio(): boolean { + return this.playbackType === PlaybackType.webaudio; + } + + public get isPaused(): boolean { + if (this.isUsingWebAudio) { + return this.bufferSourceNode.playbackRate.value === 0; + } + + return this.audio.paused; + } + + public get currentTime(): number { + if (this.isUsingWebAudio) { + return this.audioContext.currentTime - this.webAudioStartedPlayingAt - this.webAudioPausedDuration; + } + + return this.audio.currentTime; + } + + public get duration(): number { + if (this.isUsingWebAudio && this.audioBuffer) { + return this.audioBuffer.duration; + } + + return this.audio.duration; + } + + public get isActiveTrack(): boolean { + return this.queue.currentTrack?.index === this.index; + } + + public get isLoaded(): boolean { + return this.webAudioLoadingState === PlaybackLoadingState.loaded; + } + + public get state(): LimitedTrackState { + return { + playbackType: this.playbackType, + webAudioLoadingState: this.webAudioLoadingState, + }; + } + + public get completeState(): TrackState { + return { + playbackType: this.playbackType, + webAudioLoadingState: this.webAudioLoadingState, + isPaused: this.isPaused, + currentTime: this.currentTime, + duration: this.duration, + index: this.index, + }; + } + + private async loadHEAD(): Promise { + if (this.loadedHead) { + return; + } + + const { redirected, url } = await fetch(this.trackUrl, { + method: 'HEAD', + mode: this.queue.fetchMode, + }); + + if (redirected) { + this.trackUrl = url; + } + + this.loadedHead = true; + } + + private async loadBuffer(): Promise { + try { + if (this.webAudioLoadingState !== PlaybackLoadingState.none) { + return; + } + + this.webAudioLoadingState = PlaybackLoadingState.loading; + + const response = await fetch(this.trackUrl, { + mode: this.queue.fetchMode, + }); + const buffer = await response.arrayBuffer(); + this.audioBuffer = await this.audioContext.decodeAudioData(buffer); + + this.webAudioLoadingState = PlaybackLoadingState.loaded; + this.bufferSourceNode.buffer = this.audioBuffer; + this.bufferSourceNode.connect(this.gainNode); + + // try to preload next track + this.queue.loadTrack(this.index + 1); + + // if we loaded the active track, switch to web audio + if (this.isActiveTrack) { + this.switchToWebAudio(); + } + } catch (ex) { + this.debug(`Error fetching buffer: ${this.trackUrl}`, ex); + } + } + + private switchToWebAudio(): void { + // if we've switched tracks, don't switch to web audio + if (!this.isActiveTrack || !this.audioBuffer) { + return; + } + + this.debug('switch to web audio', this.currentTime, this.isPaused, this.audio.duration - this.audioBuffer.duration); + + // if currentTime === 0, this is a new track, so play it + // otherwise we're hitting this mid-track which may + // happen in the middle of a paused track + if (this.currentTime && this.isPaused) { + this.bufferSourceNode.playbackRate.value = 0; + } else { + this.bufferSourceNode.playbackRate.value = 1; + } + + this.connectGainNode(); + + this.webAudioStartedPlayingAt = this.audioContext.currentTime - this.currentTime; + + // TODO: slight blip, could be improved + this.bufferSourceNode.start(0, this.currentTime); + this.audio.pause(); + + this.playbackType = PlaybackType.webaudio; + } + + private seekBufferSourceNode(to: number): void { + const wasPaused = this.isPaused; + this.bufferSourceNode.onended = null; + this.bufferSourceNode.stop(); + + this.bufferSourceNode = this.audioContext.createBufferSource(); + + this.bufferSourceNode.buffer = this.audioBuffer; + this.bufferSourceNode.connect(this.gainNode); + this.bufferSourceNode.onended = (): void => { + this.notifyTrackEnd(); + }; + + this.webAudioStartedPlayingAt = this.audioContext.currentTime - to; + this.webAudioPausedDuration = 0; + + this.bufferSourceNode.start(0, to); + if (wasPaused) { + this.connectGainNode(); + this.pause(); + } + } + + // basic event handlers + private notifyTrackEnd(): void { + this.debug('onEnded'); + // Fire and forget + void this.queue.playNext(); + this.queue.notifyTrackEnded(); + } + + private onProgress(): void { + if (!this.isActiveTrack) { + return; + } + + const durationRemainingInSeconds = this.duration - this.currentTime; + const { nextTrack } = this.queue; + + // if in last 25 seconds and next track hasn't loaded yet, load next track using HtmlAudio + if (durationRemainingInSeconds <= 25 && nextTrack && !nextTrack.isLoaded) { + this.queue.loadTrack(this.index + 1, true); + } + + this.queue.notifyTrackProgressUpdated(); + + // if we're paused, we still want to send one final onProgress call + // and then bow out, hence this being at the end of the function + if (this.isPaused) { + return; + } + + window.requestAnimationFrame((): void => { + this.onProgress(); + }); + } + + // debug helper + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private debug(message: string, ...optionalParams: any[]): void { + // eslint-disable-next-line no-console + console.log(`${this.index}:${message}`, ...optionalParams, this.state); + } +} diff --git a/package.json b/package.json index 4f5fa34..28df82d 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,50 @@ { "name": "gapless.js", - "version": "1.0.0", + "version": "2.2.3", "description": "Gapless audio playback javascript plugin", "main": "index.js", + "types": "index.d.ts", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "clean": "rimraf index.d.ts index.js", + "build": "tsc", + "prelint": "npm run clean", + "lint": "eslint --fix \"*.{js,ts}\"", + "prepublishOnly": "npm run lint && npm run build && pinst --disable", + "postinstall": "husky install", + "postpublish": "pinst --enable" + }, + "lint-staged": { + "*.js": [ + "eslint --fix" + ], + "*.ts": [ + "eslint --fix" + ] }, "repository": { "type": "git", "url": "git+https://github.com/RelistenNet/gapless.js.git" }, - "author": "Daniel Saewitz", - "license": "MIT" + "author": "Daniel Saewitz, Jim Geurts", + "license": "MIT", + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^4.11.0", + "@typescript-eslint/parser": "^4.11.0", + "eslint": "^7.16.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-config-airbnb-typescript": "^12.0.0", + "eslint-config-prettier": "^7.1.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jsdoc": "^30.7.9", + "eslint-plugin-mocha": "^8.0.0", + "eslint-plugin-prettier": "^3.3.0", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-security": "^1.4.0", + "husky": "^5.0.6", + "lint-staged": "^10.5.3", + "pinst": "^2.1.1", + "prettier": "^2.2.1", + "rimraf": "^3.0.2", + "typescript": "^4.1.3" + } } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dcc522e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "commonjs", + "lib": ["esnext", "dom"], + "moduleResolution": "node", + + "declaration": true, + "inlineSourceMap": true, + + "esModuleInterop": true, + + "outDir": ".", + "baseUrl": ".", + + /* Additional Checks */ + "strict": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "paths": { + "*": [ + "node_modules/*" + ] + } + }, + "include": [ + "*.ts" + ], + "exclude": [ + "node_modules", + "*.js" + ] +}