diff --git a/index.html b/index.html index ca1756e..8cc59fe 100644 --- a/index.html +++ b/index.html @@ -31,8 +31,10 @@

HTML MIDI Player window.addEventListener('DOMContentLoaded', function() { for (const player of document.querySelectorAll('midi-player')) { - for (const event of ['load', 'start', 'stop', 'loop']) { - player.addEventListener(event, console.log); + for (const event of ['loadstart', 'loadeddata', 'play', 'pause', 'stop', 'durationchange', 'timeupdate', 'ended', 'loop', 'error']) { + player.addEventListener(event, (e) => { + console.log(event, e); + }); } } diff --git a/src/player.ts b/src/player.ts index 5c35e6a..80749ec 100644 --- a/src/player.ts +++ b/src/player.ts @@ -7,7 +7,7 @@ import {VisualizerElement} from './visualizer'; export type NoteEvent = CustomEvent<{note: NoteSequence.INote}>; -const VISUALIZER_EVENTS = ['start', 'stop', 'note'] as const; +const VISUALIZER_EVENTS = ['play', 'pause', 'note'] as const; const DEFAULT_SOUNDFONT = 'https://storage.googleapis.com/magentadata/js/soundfonts/sgm_plus'; let playingPlayer: PlayerElement = null; @@ -32,11 +32,13 @@ let playingPlayer: PlayerElement = null; * @prop loop - Indicates whether the player should loop * @prop currentTime - Current playback position in seconds * @prop duration - Content duration in seconds + * @prop paused - Indicates whether the player is currently paused * @prop playing - Indicates whether the player is currently playing * @attr visualizer - A selector matching `midi-visualizer` elements to bind to this player * * @fires load - The content is loaded and ready to play - * @fires start - The player has started playing + * @fires play - The player has started playing + * @fires pause - The player has paused playing * @fires stop - The player has stopped playing * @fires loop - The player has automatically restarted playback after reaching the end * @fires note - A note starts @@ -63,8 +65,10 @@ export class PlayerElement extends HTMLElement { protected visualizerListeners = new Map(); protected ns: INoteSequence = null; + protected _ended = false; protected _playing = false; protected seeking = false; + protected _lastError: any = null; // TODO: should we follow MediaError interface? static get observedAttributes() { return ['sound-font', 'src', 'visualizer']; } @@ -74,6 +78,7 @@ export class PlayerElement extends HTMLElement { this.attachShadow({mode: 'open'}); this.shadowRoot.appendChild(controlsTemplate.content.cloneNode(true)); + // TODO: hiding buttons when controls enabled this.controlPanel = this.shadowRoot.querySelector('.controls'); this.playButton = this.controlPanel.querySelector('.play'); this.currentTimeLabel = this.controlPanel.querySelector('.current-time'); @@ -97,28 +102,28 @@ export class PlayerElement extends HTMLElement { if (this.player.isPlaying()) { this.stop(); } else { - this.start(); + this.play(); } }); this.seekBar.addEventListener('input', () => { // Pause playback while the user is manipulating the control this.seeking = true; - if (this.player && this.player.getPlayState() === 'started') { + this.dispatchEvent(new CustomEvent('seeking')); + if (this.player?.getPlayState() === 'started') { this.player.pause(); } }); this.seekBar.addEventListener('change', () => { const time = this.currentTime; // This returns the seek bar value as a number this.currentTimeLabel.textContent = utils.formatTime(time); - if (this.player) { - if (this.player.isPlaying()) { - this.player.seekTo(time); - if (this.player.getPlayState() === 'paused') { - this.player.resume(); - } + if (this.player?.isPlaying()) { + this.player.seekTo(time); + if (this.player.getPlayState() === 'paused') { + this.player.resume(); } } this.seeking = false; + this.dispatchEvent(new CustomEvent('seeked')); }); this.initPlayerNow(); @@ -161,12 +166,19 @@ export class PlayerElement extends HTMLElement { let ns: INoteSequence = null; if (initNs) { if (this.src) { + this.dispatchEvent(new CustomEvent('loadstart')); this.ns = null; - this.ns = await mm.urlToNoteSequence(this.src); + try { + this.ns = await mm.urlToNoteSequence(this.src); + } catch (e) { + this.dispatchEvent(new CustomEvent('abort')); + throw e; + } } this.currentTime = 0; if (!this.ns) { this.setError('No content loaded'); + this.dispatchError('No content loaded'); } } ns = this.ns; @@ -174,9 +186,11 @@ export class PlayerElement extends HTMLElement { if (ns) { this.seekBar.max = String(ns.totalTime); this.totalTimeLabel.textContent = utils.formatTime(ns.totalTime); + this.dispatchEvent(new CustomEvent('durationchange')); } else { this.seekBar.max = '0'; this.totalTimeLabel.textContent = utils.formatTime(0); + this.dispatchEvent(new CustomEvent('durationchange')); return; } @@ -203,80 +217,113 @@ export class PlayerElement extends HTMLElement { } this.setLoaded(); - this.dispatchEvent(new CustomEvent('load')); + this.dispatchEvent(new CustomEvent('loadeddata')); + if (this.src) { + this.dispatchEvent(new CustomEvent('canplay')); + if (this.autoplay) { + await this.play(); + } + } } catch (error) { this.setError(String(error)); + this.dispatchError(error); throw error; } } - reload() { + load() { + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/load + // resets the media element to its initial state and + // begins the process of selecting a media source and loading the media in preparation for playback to begin at the beginning. this.initPlayerNow(); } - start() { - this._start(); + reload() { + this.load(); // same behavior with load } - protected _start(looped = false) { - (async () => { - if (this.player) { - if (this.player.getPlayState() == 'stopped') { - if (playingPlayer && playingPlayer.playing && !(playingPlayer == this && looped)) { - playingPlayer.stop(); - } - playingPlayer = this; - this._playing = true; + async play() { + this._start(); // not use `await` (because it waits for ended timing) - let offset = this.currentTime; - // Jump to the start if there are no notes left to play. - if (this.ns.notes.filter((note) => note.startTime > offset).length == 0) { - offset = 0; - } - this.currentTime = offset; + // `play` event fired when + // the paused prop is changed from true to false, + // as a result of the play method, + // or the autoplay attribute. + this.dispatchEvent(new CustomEvent('play')); + } - this.controlPanel.classList.remove('stopped'); - this.controlPanel.classList.add('playing'); - try { - // Force reload visualizers to prevent stuttering at playback start - for (const visualizer of this.visualizerListeners.keys()) { - if (visualizer.noteSequence != this.ns) { - visualizer.noteSequence = this.ns; - visualizer.reload(); - } - } - - const promise = this.player.start(this.ns, undefined, offset); - if (!looped) { - this.dispatchEvent(new CustomEvent('start')); - } else { - this.dispatchEvent(new CustomEvent('loop')); - } - await promise; - this.handleStop(true); - } catch (error) { - this.handleStop(); - throw error; + protected async _start(looped = false) { + if (!this.player) { + return; + } + + if (this.player.getPlayState() == 'stopped') { + if (playingPlayer && playingPlayer.playing && !(playingPlayer == this && looped)) { + playingPlayer.stop(); + } + playingPlayer = this; + this._playing = true; + + let offset = this.currentTime; + // Jump to the start if there are no notes left to play. + if (this.ns.notes.filter((note) => note.startTime > offset).length == 0) { + offset = 0; + } + this.currentTime = offset; + + this.controlPanel.classList.remove('stopped'); + this.controlPanel.classList.add('playing'); + try { + // Force reload visualizers to prevent stuttering at playback start + for (const visualizer of this.visualizerListeners.keys()) { + if (visualizer.noteSequence != this.ns) { + visualizer.noteSequence = this.ns; + visualizer.reload(); } - } else if (this.player.getPlayState() == 'paused') { - // This normally should not happen, since we pause playback only when seeking. - this.player.resume(); } + + this._ended = false; + const promise = this.player.start(this.ns, undefined, offset); + // fired after playback is first started, and whenever it is restarted + this.dispatchEvent(new CustomEvent('playing')); + if (looped) { + this.dispatchEvent(new CustomEvent('loop')); + } + await promise; + this.dispatchEvent(new CustomEvent('ended')); + this._ended = true; + this.handleStop(true); // call after dispatch 'ended' event (it will trigger loop restart) + } catch (error) { + this.handleStop(); + this.dispatchError(error); + throw error; } - })(); + } else if (this.player.getPlayState() == 'paused') { + this._ended = false; + this.player.resume(); + // fired after playback is first started, and whenever it is restarted + this.dispatchEvent(new CustomEvent('playing')); + } + } + + pause() { + if (this.player && this.player.isPlaying()) { + this.player.pause(); + } + this.handleStop(false); } stop() { if (this.player && this.player.isPlaying()) { this.player.stop(); } - this.handleStop(false); + this.handleStop(true); } addVisualizer(visualizer: VisualizerElement) { const listeners = { - start: () => { visualizer.noteSequence = this.noteSequence; }, - stop: () => { visualizer.clearActiveNotes(); }, + play: () => { visualizer.noteSequence = this.noteSequence; }, + pause: () => { visualizer.clearActiveNotes(); }, note: (event: NoteEvent) => { visualizer.redraw(event.detail.note); }, } as const; for (const name of VISUALIZER_EVENTS) { @@ -303,6 +350,15 @@ export class PlayerElement extends HTMLElement { } this.seekBar.value = String(note.startTime); this.currentTimeLabel.textContent = utils.formatTime(note.startTime); + + // TODO: more efficient way? - Currently, multiple track midi can cause too much event triggers. + this.dispatchEvent(new CustomEvent('timeupdate')); + } + + protected dispatchError(error?: unknown) { + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error_event + this._lastError = error; // TODO: implement MediaError interface + this.dispatchEvent(new CustomEvent('error')); } protected handleStop(finished = false) { @@ -318,7 +374,9 @@ export class PlayerElement extends HTMLElement { this.controlPanel.classList.add('stopped'); if (this._playing) { this._playing = false; - this.dispatchEvent(new CustomEvent('stop', {detail: {finished}})); + if (!this._ended) { + this.dispatchEvent(new CustomEvent('pause', {detail: {finished}})); + } } } @@ -379,6 +437,14 @@ export class PlayerElement extends HTMLElement { this.initPlayer(); } + get autoplay(): boolean { + return this.hasAttribute('autoplay'); + } + + set autoplay(value: boolean) { + this.setOrRemoveAttribute('autoplay', value ? '' : null); + } + get src() { return this.getAttribute('src'); } @@ -389,6 +455,10 @@ export class PlayerElement extends HTMLElement { this.initPlayer(); } + get error(): MediaError | null { + return this._lastError; + } + /** * @attr sound-font */ @@ -400,6 +470,14 @@ export class PlayerElement extends HTMLElement { this.setOrRemoveAttribute('sound-font', value); } + get controls() { + return this.hasAttribute('controls'); + } + + set controls(value: boolean) { + this.setOrRemoveAttribute('controls', value ? '' : null); + } + /** * @attr loop */ @@ -431,6 +509,29 @@ export class PlayerElement extends HTMLElement { return this._playing; } + get ended() { + return this._ended; + } + + get paused() { + const playerState = this.player?.getPlayState(); + return !playerState || playerState === 'paused' || playerState === 'stopped'; + } + + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/canPlayType + canPlayType(type: unknown): string { + // return: '' | 'maybe' | 'probably'; + // TODO: consider 'maybe' return case in some cases. + return typeof type === 'string' && ( + // type: https://developer.mozilla.org/en-US/docs/Web/Media/Formats/codecs_parameter + type.includes('audio/midi') || type.includes('audio/x-midi') + ) ? 'probably' : ''; + } + + fastSeek(seconds: number) { + this.currentTime = seconds; + } + protected setOrRemoveAttribute(name: string, value: string) { if (value == null) { this.removeAttribute(name);