Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: follow HTMLMediaElement interface (Breaking Change) #66

Draft
wants to merge 23 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3723e02
feat: define `play()` `pause()` `stop()`
leo6104 May 31, 2023
998ee23
refactor(player): use async await pattern for play()
leo6104 Jun 1, 2023
bf10ce5
refactor: change event name to 'play', 'pause'
leo6104 Jun 1, 2023
41ee92c
feat(canPlayType): add playType spec with partially support
leo6104 Jun 1, 2023
48ba7f7
feat: add paused property
leo6104 Jun 1, 2023
ee3d180
feat: add error event timing
leo6104 Jun 1, 2023
f07f8a4
feat: add error property
leo6104 Jun 1, 2023
db02adc
feat: add 'loadeddata', 'loadstart', 'canplay' event timing in `initP…
leo6104 Jun 1, 2023
ed10c4a
feat: add 'play', 'playing', 'ended' event timing
leo6104 Jun 1, 2023
e18e415
feat: add `load()` instance method (same with reload())
leo6104 Jun 1, 2023
7d3a5cc
feat: add 'timeupdate' event in note callback
leo6104 Jun 1, 2023
186e5bc
feat: support `autoplay` property
leo6104 Jun 1, 2023
5499b8e
fix(autoplay): add setter for autoplay
leo6104 Jun 1, 2023
258364a
refactor: do not specify 'cancellable', 'bubbles' (default value is f…
leo6104 Jun 1, 2023
5125ad0
feat: add fastSeek (same behavior with currentTime setter)
leo6104 Jun 1, 2023
499b1e1
feat: add 'seeking', 'seeked' event timing
leo6104 Jun 1, 2023
570262d
feat: add durationchange event for totalTime (duration)
leo6104 Jun 1, 2023
e036a3b
feat: add controls property input
leo6104 Jun 1, 2023
ecfc4d1
feat: add abort event timing
leo6104 Jun 1, 2023
fd6e1f6
refactor(canPlayType): use String.includes to check type value
leo6104 Jun 1, 2023
f285c9d
feat: add ended property & correct behavior for 'ended' event (consid…
leo6104 Jun 1, 2023
0d2bb91
refactor(paused): return false when !playerState || stopped case
leo6104 Jun 1, 2023
b507595
feat: add more event listeners in index.html (for test)
leo6104 Jun 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ <h1><a href="https://github.com/cifkao/html-midi-player">HTML MIDI Player</a></h
<script>
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);
});
}
}

Expand Down
221 changes: 161 additions & 60 deletions src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -63,8 +65,10 @@ export class PlayerElement extends HTMLElement {
protected visualizerListeners = new Map<VisualizerElement, {[name: string]: EventListener}>();

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']; }

Expand All @@ -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');
Expand All @@ -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();
Expand Down Expand Up @@ -161,22 +166,31 @@ 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;

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;
}

Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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}}));
}
}
}

Expand Down Expand Up @@ -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');
}
Expand All @@ -389,6 +455,10 @@ export class PlayerElement extends HTMLElement {
this.initPlayer();
}

get error(): MediaError | null {
return this._lastError;
}

/**
* @attr sound-font
*/
Expand All @@ -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
*/
Expand Down Expand Up @@ -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);
Expand Down