diff --git a/.eslintrc.js b/.eslintrc.js index 921228c..cb94a48 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,171 +1,296 @@ -'use strict'; - module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: './tsconfig.json', - }, + root: true, plugins: [ - "jsdoc", - "promise", - "security", - '@typescript-eslint' - ], - extends: [ - 'eslint:recommended', - 'airbnb-base', - 'plugin:@typescript-eslint/recommended', - 'plugin:import/typescript', + '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: { - "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": "off", - "no-template-curly-in-string": "error", - "no-undef": "error", - "no-underscore-dangle": "off", - "no-unsafe-negation": "error", - "no-use-before-define": "off", - "no-useless-rename": "error", - "padding-line-between-statements": ["error", + '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: '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": "*"} + { 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-empty": "error", - "no-empty-function": "error", - "valid-jsdoc": "off", - "yoda": "error", + strict: ['error', 'safe'], + 'no-new': 'off', + 'no-empty': 'error', + 'no-empty-function': 'error', + 'valid-jsdoc': 'off', + yoda: 'error', - "import/no-unresolved": "off", - 'import/prefer-default-export': 'off', - 'import/no-extraneous-dependencies': 'off', + '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', - "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", + '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"]}], - 'indent': 'off', - "no-param-reassign": "off", // Work toward enforcing this rule - "radix": "off", - "spaced-comment": "off", - "max-len": "off", - "no-continue": "off", - 'no-dupe-class-members': 'off', - "no-plusplus": "off", - "no-prototype-builtins": "off", - "no-restricted-syntax": [ - "error", - "DebuggerStatement", - "LabeledStatement", - "WithStatement" + 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-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-constructor': 'off', - "no-useless-escape": "off", - "object-shorthand": ["error", "always", { - "ignoreConstructors": false, - "avoidQuotes": true, - "avoidExplicitReturnArrows": true - }], - "prefer-spread": "error", - "prefer-destructuring": "off", + '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', - '@typescript-eslint/await-thenable': 'error', - '@typescript-eslint/ban-ts-ignore': 'off', - '@typescript-eslint/no-extraneous-class': 'error', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-member-accessibility': ["error"], - '@typescript-eslint/interface-name-prefix': ['error', 'never'], - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-for-in-array': 'error', - '@typescript-eslint/no-require-imports': 'error', - '@typescript-eslint/no-this-alias': 'error', - '@typescript-eslint/no-useless-constructor': 'error', - '@typescript-eslint/no-unused-vars': 'error', - '@typescript-eslint/prefer-for-of': 'error', - '@typescript-eslint/prefer-includes': 'error', - '@typescript-eslint/prefer-string-starts-ends-with': 'error', - '@typescript-eslint/promise-function-async': 'off', - '@typescript-eslint/restrict-plus-operands': 'error', + '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 }, + }, + ], - // Special to this project - 'max-classes-per-file': 'off', - '@typescript-eslint/max-classes-per-file': 'off', - }, + '@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/.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/.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 index 30d6d81..66e26db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +### 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 diff --git a/index.ts b/index.ts index 316a534..6d83051 100644 --- a/index.ts +++ b/index.ts @@ -1,12 +1,12 @@ enum PlaybackType { html5, - webaudio + webaudio, } enum PlaybackLoadingState { none, loading, - loaded + loaded, } export interface QueueOptions { @@ -36,21 +36,25 @@ interface QueueProps { onStartNewTrack?: (track: Track) => void; } -const AudioContext = window.AudioContext - // @ts-ignore - || window.webkitAudioContext; +interface WindowWithWebkitAudioContext extends Window { + webkitAudioContext: AudioContext; +} -export class Queue { - public state: QueueState; +declare let window: WindowWithWebkitAudioContext & typeof globalThis; +const AudioContext = window.AudioContext || window.webkitAudioContext; + +export class Queue { private props: QueueProps; private numberOfTracksToPreload: number; private tracks: Track[]; + public state: QueueState; + public constructor({ - tracks = [], + tracks = [], // onProgress, onEnded, onPlayNextTrack, @@ -76,21 +80,18 @@ export class Queue { }; // eslint-disable-next-line @typescript-eslint/no-use-before-define - this.tracks = tracks.map((trackUrl, index) => new Track({ - trackUrl, - index, - queue: this, - metadata: {} as TTrackMetadata, - })); + this.tracks = tracks.map( + (trackUrl, index) => + new Track({ + trackUrl, + index, + queue: this, + metadata: {} as TTrackMetadata, + }), + ); } - public addTrack({ - trackUrl, - metadata = {} as TTrackMetadata, - }: { - trackUrl: string; - metadata: TTrackMetadata; - }) { + 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({ @@ -102,30 +103,30 @@ export class Queue { ); } - public removeTrack(track: Track) { + public removeTrack(track: Track): void { const index = this.tracks.indexOf(track); this.tracks.splice(index, 1); } - public togglePlayPause() { + public async togglePlayPause(): Promise { if (this.currentTrack) { - this.currentTrack.togglePlayPause(); + await this.currentTrack.togglePlayPause(); } } - public async play() { + public async play(): Promise { if (this.currentTrack) { - return this.currentTrack.play(); + await this.currentTrack.play(); } } - public pause() { + public pause(): void { if (this.currentTrack) { this.currentTrack.pause(); } } - public async playPrevious() { + public async playPrevious(): Promise { this.resetCurrentTrack(); this.state.currentTrackIndex = Math.max(this.state.currentTrackIndex - 1, 0); @@ -143,10 +144,10 @@ export class Queue { } } - public async playNext() { + public async playNext(): Promise { this.resetCurrentTrack(); - this.state.currentTrackIndex++; + this.state.currentTrackIndex += 1; this.resetCurrentTrack(); @@ -161,20 +162,20 @@ export class Queue { } } - public resetCurrentTrack() { + public resetCurrentTrack(): void { if (this.currentTrack) { this.currentTrack.seek(0); this.currentTrack.pause(); } } - public pauseAll() { + public pauseAll(): void { for (const track of this.tracks) { track.pause(); } } - public async gotoTrack(trackIndex: number, playImmediately = false) { + public async gotoTrack(trackIndex: number, playImmediately = false): Promise { this.pauseAll(); this.state.currentTrackIndex = trackIndex; @@ -189,7 +190,7 @@ export class Queue { } } - public loadTrack(trackIndex: number, useHtmlAudioPreloading = false) { + public loadTrack(trackIndex: number, useHtmlAudioPreloading = false): void { // only preload if song is within the next 2 if (this.state.currentTrackIndex + this.numberOfTracksToPreload <= trackIndex) { return; @@ -203,14 +204,14 @@ export class Queue { } // Internal - Used by the track to notify when it has ended - public notifyTrackEnded() { + public notifyTrackEnded(): void { if (this.props.onEnded) { this.props.onEnded(); } } // Internal - Used by the track to notify when progress has updated - public notifyTrackProgressUpdated() { + public notifyTrackProgressUpdated(): void { if (this.props.onProgress) { this.props.onProgress(this.currentTrack); } @@ -220,15 +221,15 @@ export class Queue { return this.tracks[this.state.currentTrackIndex]; } - public get nextTrack() { + public get nextTrack(): Track { return this.tracks[this.state.currentTrackIndex + 1]; } - public disableWebAudio() { + public disableWebAudio(): void { this.state.webAudioIsDisabled = true; } - public setVolume(volume: number) { + public setVolume(volume: number): void { if (volume < 0) { volume = 0; } else if (volume > 1) { @@ -250,13 +251,19 @@ interface TrackOptions { metadata: TTrackMetadata; } -export class Track { - public metadata: TTrackMetadata; - - public index: number; +interface LimitedTrackState { + playbackType: PlaybackType; + webAudioLoadingState: PlaybackLoadingState; +} - public trackUrl: string; +interface TrackState extends LimitedTrackState { + isPaused: boolean; + currentTime: number; + duration: number; + index: number; +} +export class Track { private playbackType: PlaybackType; private webAudioLoadingState: PlaybackLoadingState; @@ -281,9 +288,13 @@ export class Track { private bufferSourceNode: AudioBufferSourceNode; - public constructor({ - trackUrl, queue, index, metadata, -}: TrackOptions) { + 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; @@ -300,11 +311,13 @@ export class Track { // HTML5 Audio this.audio = new Audio(); - this.audio.onerror = (e: Event | string) => { + this.audio.onerror = (e: Event | string): void => { this.debug('audioOnError', e); }; - this.audio.onended = this.notifyTrackEnd; + this.audio.onended = (): void => { + this.notifyTrackEnd(); + }; this.audio.controls = false; this.audio.volume = this.queue.state.volume; this.audio.preload = 'none'; @@ -321,89 +334,12 @@ export class Track { this.audioBuffer = null; this.bufferSourceNode = this.audioContext.createBufferSource(); - this.bufferSourceNode.onended = this.notifyTrackEnd; - } - - // private functions - private async loadHEAD() { - if (this.loadedHead) { - return; - } - - const { redirected, url } = await fetch(this.trackUrl, { - method: 'HEAD', - }); - - if (redirected) { - this.trackUrl = url; - } - - this.loadedHead = true; - } - - private async loadBuffer() { - try { - if (this.webAudioLoadingState !== PlaybackLoadingState.none) { - return; - } - - this.webAudioLoadingState = PlaybackLoadingState.loading; - - const response = await fetch(this.trackUrl); - 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() { - // 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; + this.bufferSourceNode.onended = (): void => { + this.notifyTrackEnd(); + }; } - // public-ish functions - public pause() { + public pause(): void { this.debug('pause'); if (this.isUsingWebAudio) { if (this.bufferSourceNode.playbackRate.value === 0) { @@ -418,7 +354,7 @@ export class Track { } } - public async play() { + public async play(): Promise { this.debug('play'); if (this.audioBuffer) { // if we've already set up the buffer just set playbackRate to 1 @@ -428,8 +364,7 @@ export class Track { } if (this.webAudioPausedAt) { - this.webAudioPausedDuration - += this.audioContext.currentTime - this.webAudioPausedAt; + this.webAudioPausedDuration += this.audioContext.currentTime - this.webAudioPausedAt; } // use seek to avoid bug where track wouldn't play properly @@ -454,7 +389,10 @@ export class Track { if (!this.queue.state.webAudioIsDisabled) { // Fire and forget this.loadHEAD() - .then(this.loadBuffer) + .then(() => { + void this.loadBuffer(); + return true; + }) .catch(() => undefined); } } @@ -462,28 +400,31 @@ export class Track { this.onProgress(); } - public togglePlayPause() { + public async togglePlayPause(): Promise { if (this.isPaused) { - this.play(); + await this.play(); } else { this.pause(); } } - public preload(useHtmlAudioPreloading = false) { + 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(this.loadBuffer) + .then(() => { + void this.loadBuffer(); + return true; + }) .catch(() => undefined); } } // TODO: add checks for to > duration or null or negative (duration - to) - public seek(to = 0) { + public seek(to = 0): void { if (this.isUsingWebAudio) { this.seekBufferSourceNode(to); } else { @@ -493,64 +434,11 @@ export class Track { this.onProgress(); } - private seekBufferSourceNode(to: number) { - 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.notifyTrackEnd; - - this.webAudioStartedPlayingAt = this.audioContext.currentTime - to; - this.webAudioPausedDuration = 0; - - this.bufferSourceNode.start(0, to); - if (wasPaused) { - this.connectGainNode(); - this.pause(); - } - } - - public connectGainNode() { + public connectGainNode(): void { this.gainNode.connect(this.audioContext.destination); } - // basic event handlers - private notifyTrackEnd() { - this.debug('onEnded'); - // Fire and forget - this.queue.playNext(); - this.queue.notifyTrackEnded(); - } - - private onProgress() { - if (!this.isActiveTrack) { - return; - } - - const durationRemainingInSeconds = this.duration - this.currentTime; - const nextTrack = this.queue.nextTrack; - - // 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(this.onProgress); - } - - public setVolume(volume: number) { + public setVolume(volume: number): void { this.audio.volume = volume; if (this.gainNode) { this.gainNode.gain.value = volume; @@ -594,14 +482,14 @@ export class Track { return this.webAudioLoadingState === PlaybackLoadingState.loaded; } - public get state() { + public get state(): LimitedTrackState { return { playbackType: this.playbackType, webAudioLoadingState: this.webAudioLoadingState, }; } - public get completeState() { + public get completeState(): TrackState { return { playbackType: this.playbackType, webAudioLoadingState: this.webAudioLoadingState, @@ -612,21 +500,139 @@ export class Track { }; } - // debug helper - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private debug(message: string, ...optionalParams: any[]) { - // eslint-disable-next-line no-console - console.log(`${this.index}:${message}`, ...optionalParams, this.state); + private async loadHEAD(): Promise { + if (this.loadedHead) { + return; + } + + const { redirected, url } = await fetch(this.trackUrl, { + method: 'HEAD', + }); + + if (redirected) { + this.trackUrl = url; + } + + this.loadedHead = true; } - /** - * @deprecated - */ - public seekToEnd(): void { - if (this.isUsingWebAudio && this.audioBuffer) { - this.seekBufferSourceNode(this.audioBuffer.duration - 6); + private async loadBuffer(): Promise { + try { + if (this.webAudioLoadingState !== PlaybackLoadingState.none) { + return; + } + + this.webAudioLoadingState = PlaybackLoadingState.loading; + + const response = await fetch(this.trackUrl); + 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.audio.currentTime = this.audio.duration - 6; + 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 1035930..f2bf721 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,17 @@ { "name": "gapless.js", - "version": "2.0.0", + "version": "2.1.0", "description": "Gapless audio playback javascript plugin", "main": "index.js", "types": "index.d.ts", "scripts": { "clean": "rimraf index.d.ts index.js", "build": "tsc", - "lint": "eslint '**/*.{js,ts,tsx}' --fix", - "prepublish": "npm run lint && npm run build" + "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": [ @@ -18,11 +21,6 @@ "eslint --fix" ] }, - "husky": { - "hooks": { - "pre-commit": "lint-staged" - } - }, "repository": { "type": "git", "url": "git+https://github.com/jgeurts/gapless.js.git" @@ -30,17 +28,23 @@ "author": "Daniel Saewitz, Jim Geurts", "license": "MIT", "devDependencies": { - "@typescript-eslint/eslint-plugin": "2.7.0", - "@typescript-eslint/parser": "2.7.0", - "eslint": "6.6.0", - "eslint-config-airbnb-base": "14.0.0", - "eslint-plugin-import": "2.18.2", - "eslint-plugin-jsdoc": "17.1.1", - "eslint-plugin-promise": "4.2.1", - "eslint-plugin-security": "1.4.0", - "husky": "3.0.9", - "lint-staged": "9.4.2", - "rimraf": "^3.0.0", - "typescript": "3.7.2" + "@typescript-eslint/eslint-plugin": "^4.8.2", + "@typescript-eslint/parser": "^4.8.2", + "eslint": "^7.13.0", + "eslint-config-airbnb-base": "^14.2.1", + "eslint-config-airbnb-typescript": "^12.0.0", + "eslint-config-prettier": "^6.15.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jsdoc": "^30.7.8", + "eslint-plugin-mocha": "^8.0.0", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-security": "^1.4.0", + "husky": "^5.0.4", + "lint-staged": "^10.5.2", + "pinst": "^2.1.1", + "prettier": "^2.2.1", + "rimraf": "^3.0.2", + "typescript": "^4.1.2" } }