@arraypress/waveform-player 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/js/core.js CHANGED
@@ -253,8 +253,18 @@ export class WaveformPlayer {
253
253
  /**
254
254
  * Create audio element
255
255
  * @private
256
+ *
257
+ * No-op in `audioMode: 'external'` — the player has no audio of its
258
+ * own; an external controller (e.g. WaveformBar) owns playback and
259
+ * pushes state in via setPlayingState() / setProgress(). The
260
+ * `this.audio` field stays null in that mode; downstream code must
261
+ * null-check it.
256
262
  */
257
263
  createAudio() {
264
+ if (this.options.audioMode === 'external') {
265
+ this.audio = null;
266
+ return;
267
+ }
258
268
  this.audio = new Audio();
259
269
  this.audio.preload = this.options.preload || 'metadata';
260
270
  this.audio.crossOrigin = 'anonymous';
@@ -269,8 +279,12 @@ export class WaveformPlayer {
269
279
  * @private
270
280
  */
271
281
  initPlaybackSpeed() {
272
- // Set initial playback rate if specified
273
- if (this.options.playbackRate && this.options.playbackRate !== 1) {
282
+ // External mode has no <audio> element, so the speed control
283
+ // doesn't apply locally the external controller (e.g.
284
+ // WaveformBar) owns playback rate. Skip the audio init but
285
+ // still bind the speed control UI in case the controller
286
+ // wants to mirror rate changes via events later.
287
+ if (this.audio && this.options.playbackRate && this.options.playbackRate !== 1) {
274
288
  this.audio.playbackRate = this.options.playbackRate;
275
289
  }
276
290
 
@@ -336,30 +350,37 @@ export class WaveformPlayer {
336
350
  this.container.focus();
337
351
  });
338
352
 
339
- // Keyboard events
353
+ // Keyboard events. In external mode `this.audio` is null, so
354
+ // seek/volume/mute keys are no-ops (the external controller
355
+ // owns those). Space (togglePlay) still works because togglePlay
356
+ // routes through the request-play/pause events.
340
357
  this.container.addEventListener('keydown', (e) => {
341
358
  if (document.activeElement !== this.container) return;
342
359
 
343
360
  const key = e.key;
344
- const currentTime = this.audio.currentTime;
361
+ const hasAudio = !!this.audio;
362
+ const currentTime = hasAudio ? this.audio.currentTime : 0;
345
363
 
346
364
  // Handle number keys 0-9 for seeking
347
- if (key >= '0' && key <= '9') {
365
+ if (hasAudio && key >= '0' && key <= '9') {
348
366
  e.preventDefault();
349
367
  this.seekToPercent(parseInt(key) / 10);
350
368
  return;
351
369
  }
352
370
 
353
- // Handle other keys
371
+ // Handle other keys. Space always works (dispatches
372
+ // request-play in external mode); audio-bound keys only
373
+ // when we own the <audio> element.
354
374
  const actions = {
355
375
  ' ': () => this.togglePlay(),
356
- 'ArrowLeft': () => this.seekTo(Math.max(0, currentTime - 5)),
357
- 'ArrowRight': () => this.seekTo(Math.min(this.audio.duration, currentTime + 5)),
358
- 'ArrowUp': () => this.setVolume(Math.min(1, this.audio.volume + 0.1)),
359
- 'ArrowDown': () => this.setVolume(Math.max(0, this.audio.volume - 0.1)),
360
- 'm': () => this.audio.muted = !this.audio.muted,
361
- 'M': () => this.audio.muted = !this.audio.muted
362
376
  };
377
+ if (hasAudio) {
378
+ actions['ArrowLeft'] = () => this.seekTo(Math.max(0, currentTime - 5));
379
+ actions['ArrowRight'] = () => this.seekTo(Math.min(this.audio.duration, currentTime + 5));
380
+ actions['ArrowUp'] = () => this.setVolume(Math.min(1, this.audio.volume + 0.1));
381
+ actions['ArrowDown'] = () => this.setVolume(Math.max(0, this.audio.volume - 0.1));
382
+ actions['m'] = actions['M'] = () => this.audio.muted = !this.audio.muted;
383
+ }
363
384
 
364
385
  if (actions[key]) {
365
386
  e.preventDefault();
@@ -374,6 +395,10 @@ export class WaveformPlayer {
374
395
  */
375
396
  initMediaSession() {
376
397
  if (!('mediaSession' in navigator) || !this.options.enableMediaSession) return;
398
+ // Skip Media Session in external mode — the controller (e.g.
399
+ // WaveformBar) owns audio playback and registers its own Media
400
+ // Session handlers; ours would conflict with its.
401
+ if (!this.audio) return;
377
402
 
378
403
  // Set metadata
379
404
  navigator.mediaSession.metadata = new MediaMetadata({
@@ -410,21 +435,30 @@ export class WaveformPlayer {
410
435
  * @private
411
436
  */
412
437
  bindEvents() {
413
- // Play button (only if controls are shown)
438
+ // Play button (only if controls are shown). In external mode
439
+ // togglePlay() dispatches the request-play/pause events so the
440
+ // controller can decide what to do; the click still goes through
441
+ // here.
414
442
  if (this.playBtn) {
415
443
  this.playBtn.addEventListener('click', () => this.togglePlay());
416
444
  }
417
445
 
418
- // Audio events
419
- this.audio.addEventListener('loadstart', () => this.setLoading(true));
420
- this.audio.addEventListener('loadedmetadata', () => this.onMetadataLoaded());
421
- this.audio.addEventListener('canplay', () => this.setLoading(false));
422
- this.audio.addEventListener('play', () => this.onPlay());
423
- this.audio.addEventListener('pause', () => this.onPause());
424
- this.audio.addEventListener('ended', () => this.onEnded());
425
- this.audio.addEventListener('error', (e) => this.onError(e));
446
+ // Audio events — only when we own an <audio> element. External
447
+ // mode receives state via setPlayingState() / setProgress() from
448
+ // the controller, so we have nothing to listen to here.
449
+ if (this.audio) {
450
+ this.audio.addEventListener('loadstart', () => this.setLoading(true));
451
+ this.audio.addEventListener('loadedmetadata', () => this.onMetadataLoaded());
452
+ this.audio.addEventListener('canplay', () => this.setLoading(false));
453
+ this.audio.addEventListener('play', () => this.onPlay());
454
+ this.audio.addEventListener('pause', () => this.onPause());
455
+ this.audio.addEventListener('ended', () => this.onEnded());
456
+ this.audio.addEventListener('error', (e) => this.onError(e));
457
+ }
426
458
 
427
- // Canvas interactions
459
+ // Canvas interactions — seek-on-click. In external mode the
460
+ // canvas click dispatches a `waveformplayer:request-seek` event
461
+ // so the controller can position its own audio element.
428
462
  this.canvas.addEventListener('click', (e) => this.handleCanvasClick(e));
429
463
 
430
464
  // Window resize - store handler for cleanup
@@ -463,24 +497,31 @@ export class WaveformPlayer {
463
497
  this.progress = 0;
464
498
  this.hasError = false;
465
499
 
466
- // Set audio source
467
- this.audio.src = url;
468
-
469
- // Wait for metadata to load
470
- await new Promise((resolve, reject) => {
471
- const metadataHandler = () => {
472
- this.audio.removeEventListener('loadedmetadata', metadataHandler);
473
- this.audio.removeEventListener('error', errorHandler);
474
- resolve();
475
- };
476
- const errorHandler = (e) => {
477
- this.audio.removeEventListener('loadedmetadata', metadataHandler);
478
- this.audio.removeEventListener('error', errorHandler);
479
- reject(e);
480
- };
481
- this.audio.addEventListener('loadedmetadata', metadataHandler);
482
- this.audio.addEventListener('error', errorHandler);
483
- });
500
+ // In external mode we don't own an <audio> element — skip
501
+ // src assignment + metadata-wait, but still generate the
502
+ // waveform peaks so the canvas can render the visualization.
503
+ // Duration / current time come from the external controller
504
+ // via setProgress().
505
+ if (this.audio) {
506
+ // Set audio source
507
+ this.audio.src = url;
508
+
509
+ // Wait for metadata to load
510
+ await new Promise((resolve, reject) => {
511
+ const metadataHandler = () => {
512
+ this.audio.removeEventListener('loadedmetadata', metadataHandler);
513
+ this.audio.removeEventListener('error', errorHandler);
514
+ resolve();
515
+ };
516
+ const errorHandler = (e) => {
517
+ this.audio.removeEventListener('loadedmetadata', metadataHandler);
518
+ this.audio.removeEventListener('error', errorHandler);
519
+ reject(e);
520
+ };
521
+ this.audio.addEventListener('loadedmetadata', metadataHandler);
522
+ this.audio.addEventListener('error', errorHandler);
523
+ });
524
+ }
484
525
 
485
526
  // Set title
486
527
  const title = this.options.title || extractTitleFromUrl(url);
@@ -538,9 +579,11 @@ export class WaveformPlayer {
538
579
  this.pause();
539
580
  }
540
581
 
541
- // Reset audio element completely
542
- this.audio.src = '';
543
- this.audio.load();
582
+ // Reset audio element completely (only when we own one)
583
+ if (this.audio) {
584
+ this.audio.src = '';
585
+ this.audio.load();
586
+ }
544
587
 
545
588
  // Clear any errors
546
589
  this.hasError = false;
@@ -567,7 +610,7 @@ export class WaveformPlayer {
567
610
  });
568
611
 
569
612
  // Apply preload setting if it was changed
570
- if (options.preload) {
613
+ if (options.preload && this.audio) {
571
614
  this.audio.preload = options.preload;
572
615
  }
573
616
 
@@ -605,12 +648,16 @@ export class WaveformPlayer {
605
648
  * @private
606
649
  */
607
650
  setWaveformData(data) {
608
- // URL to JSON file — fetch peaks
651
+ // URL to JSON file — fetch peaks and maybe markers
609
652
  if (typeof data === 'string' && data.trim().endsWith('.json')) {
610
653
  fetch(data.trim())
611
654
  .then(r => r.json())
612
655
  .then(json => {
613
656
  this.waveformData = Array.isArray(json) ? json : (json.peaks || []);
657
+ if (json.markers && !this.options.markers?.length) {
658
+ this.options.markers = json.markers;
659
+ this.renderMarkers();
660
+ }
614
661
  this.drawWaveform();
615
662
  })
616
663
  .catch(() => {});
@@ -727,12 +774,31 @@ export class WaveformPlayer {
727
774
  * @private
728
775
  */
729
776
  handleCanvasClick(event) {
730
- if (!this.audio.duration) return;
731
-
777
+ // In external mode the player has no audio of its own —
778
+ // dispatch a cancelable `waveformplayer:request-seek` event
779
+ // with the target percentage so the controller can seek its
780
+ // own audio. Locally we just update the visual progress so
781
+ // the canvas paints the new position immediately (the
782
+ // controller's progress event will reconcile shortly after).
732
783
  const rect = this.canvas.getBoundingClientRect();
733
784
  const x = event.clientX - rect.left;
734
785
  const targetPercent = Math.max(0, Math.min(1, x / rect.width));
735
786
 
787
+ if (this.options.audioMode === 'external') {
788
+ const evt = new CustomEvent('waveformplayer:request-seek', {
789
+ bubbles: true,
790
+ cancelable: true,
791
+ detail: { ...this._buildTrackDetail(), percent: targetPercent }
792
+ });
793
+ this.container.dispatchEvent(evt);
794
+ if (!evt.defaultPrevented) {
795
+ this.progress = targetPercent;
796
+ this.drawWaveform?.();
797
+ }
798
+ return;
799
+ }
800
+
801
+ if (!this.audio || !this.audio.duration) return;
736
802
  this.seekToPercent(targetPercent);
737
803
  }
738
804
 
@@ -897,7 +963,10 @@ export class WaveformPlayer {
897
963
  this.stopSmoothUpdate();
898
964
 
899
965
  const update = () => {
900
- if (this.isPlaying && this.audio.duration) {
966
+ // In external mode the canvas redraws are driven by
967
+ // setProgress() pushes from the controller — no internal
968
+ // RAF needed. Self-mode keeps the smooth-update loop.
969
+ if (this.isPlaying && this.audio && this.audio.duration) {
901
970
  this.updateProgress();
902
971
  this.updateTimer = requestAnimationFrame(update);
903
972
  }
@@ -922,7 +991,9 @@ export class WaveformPlayer {
922
991
  * @private
923
992
  */
924
993
  updateProgress() {
925
- if (!this.audio.duration) return;
994
+ // Self-mode only — external mode receives progress via
995
+ // setProgress() from the controller and never calls this.
996
+ if (!this.audio || !this.audio.duration) return;
926
997
 
927
998
  const newProgress = this.audio.currentTime / this.audio.duration;
928
999
 
@@ -988,8 +1059,20 @@ export class WaveformPlayer {
988
1059
  // ============================================
989
1060
 
990
1061
  /**
991
- * Play audio
992
- * @return {Promise} The promise returned by HTMLMediaElement.play()
1062
+ * Play audio.
1063
+ *
1064
+ * In `audioMode: 'self'` (default): calls the underlying <audio>
1065
+ * element's play(). Returns the promise from HTMLMediaElement.play().
1066
+ *
1067
+ * In `audioMode: 'external'`: dispatches a cancelable
1068
+ * `waveformplayer:request-play` event with the track metadata and
1069
+ * does NOT touch any audio element. Returns `undefined`. An external
1070
+ * controller (e.g. WaveformBar) listens for this event and starts
1071
+ * playback on its own audio source, then pushes state back via
1072
+ * setPlayingState() / setProgress(). Calling preventDefault() on
1073
+ * the event lets the controller veto the play (state is unchanged).
1074
+ *
1075
+ * @return {Promise|undefined}
993
1076
  */
994
1077
  play() {
995
1078
  if (this.options.singlePlay && WaveformPlayer.currentlyPlaying &&
@@ -997,20 +1080,128 @@ export class WaveformPlayer {
997
1080
  WaveformPlayer.currentlyPlaying.pause();
998
1081
  }
999
1082
 
1083
+ if (this.options.audioMode === 'external') {
1084
+ const evt = new CustomEvent('waveformplayer:request-play', {
1085
+ bubbles: true,
1086
+ cancelable: true,
1087
+ detail: this._buildTrackDetail()
1088
+ });
1089
+ this.container.dispatchEvent(evt);
1090
+ // If the controller cancels (preventDefault), don't claim
1091
+ // "currentlyPlaying" — the controller didn't accept the play.
1092
+ if (!evt.defaultPrevented) {
1093
+ WaveformPlayer.currentlyPlaying = this;
1094
+ }
1095
+ return undefined;
1096
+ }
1097
+
1000
1098
  WaveformPlayer.currentlyPlaying = this;
1001
1099
  return this.audio.play();
1002
1100
  }
1003
1101
 
1004
1102
  /**
1005
- * Pause audio
1103
+ * Pause audio.
1104
+ *
1105
+ * In `audioMode: 'external'`, dispatches `waveformplayer:request-pause`
1106
+ * (cancelable) and does NOT touch any audio element. See play().
1006
1107
  */
1007
1108
  pause() {
1008
1109
  if (WaveformPlayer.currentlyPlaying === this) {
1009
1110
  WaveformPlayer.currentlyPlaying = null;
1010
1111
  }
1112
+ if (this.options.audioMode === 'external') {
1113
+ this.container.dispatchEvent(new CustomEvent('waveformplayer:request-pause', {
1114
+ bubbles: true,
1115
+ cancelable: true,
1116
+ detail: this._buildTrackDetail()
1117
+ }));
1118
+ return;
1119
+ }
1011
1120
  this.audio.pause();
1012
1121
  }
1013
1122
 
1123
+ /**
1124
+ * Build the track detail object dispatched by request-play /
1125
+ * request-pause events in external audio mode. Mirrors the shape
1126
+ * WaveformBar.play() accepts so a controller can forward it
1127
+ * directly: `WaveformBar.play(event.detail)`.
1128
+ *
1129
+ * @private
1130
+ * @return {{url:string,title:?string,subtitle:?string,artist:?string,artwork:?string,player:WaveformPlayer}}
1131
+ */
1132
+ _buildTrackDetail() {
1133
+ return {
1134
+ url: this.options.url,
1135
+ title: this.options.title,
1136
+ subtitle: this.options.subtitle,
1137
+ artist: this.options.artist,
1138
+ artwork: this.options.artwork,
1139
+ id: this.id,
1140
+ player: this
1141
+ };
1142
+ }
1143
+
1144
+ /**
1145
+ * External-mode state pump: flip the play/pause visual state without
1146
+ * touching audio. Mirrors what onPlay()/onPause() do but skips the
1147
+ * audio-element interactions. Safe to call repeatedly — idempotent.
1148
+ *
1149
+ * @param {boolean} playing
1150
+ */
1151
+ setPlayingState(playing) {
1152
+ const wasPlaying = this.isPlaying;
1153
+ this.isPlaying = !!playing;
1154
+ if (this.playBtn) {
1155
+ this.playBtn.classList.toggle('playing', this.isPlaying);
1156
+ const playIcon = this.playBtn.querySelector('.waveform-icon-play');
1157
+ const pauseIcon = this.playBtn.querySelector('.waveform-icon-pause');
1158
+ if (playIcon) playIcon.style.display = this.isPlaying ? 'none' : 'flex';
1159
+ if (pauseIcon) pauseIcon.style.display = this.isPlaying ? 'flex' : 'none';
1160
+ }
1161
+ if (this.isPlaying && !wasPlaying) {
1162
+ this.startSmoothUpdate?.();
1163
+ this.container.dispatchEvent(new CustomEvent('waveformplayer:play', {
1164
+ bubbles: true,
1165
+ detail: {player: this, url: this.options.url}
1166
+ }));
1167
+ if (this.options.onPlay) this.options.onPlay(this);
1168
+ } else if (!this.isPlaying && wasPlaying) {
1169
+ this.stopSmoothUpdate?.();
1170
+ this.container.dispatchEvent(new CustomEvent('waveformplayer:pause', {
1171
+ bubbles: true,
1172
+ detail: {player: this, url: this.options.url}
1173
+ }));
1174
+ if (this.options.onPause) this.options.onPause(this);
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * External-mode state pump: update the visualization's progress
1180
+ * from an external clock (e.g. WaveformBar's audio element's
1181
+ * timeupdate). Drives the canvas redraw + the time displays.
1182
+ *
1183
+ * @param {number} currentTime Current playback position in seconds.
1184
+ * @param {number} duration Total track duration in seconds.
1185
+ */
1186
+ setProgress(currentTime, duration) {
1187
+ if (!duration || duration <= 0) return;
1188
+ this.progress = Math.max(0, Math.min(1, currentTime / duration));
1189
+ // Mirror the existing display update code so callers don't have
1190
+ // to know which DOM elements live where.
1191
+ if (this.currentTimeEl) this.currentTimeEl.textContent = formatTime(currentTime);
1192
+ if (this.totalTimeEl && (!this.totalTimeEl.dataset._extSet || this._extDuration !== duration)) {
1193
+ this.totalTimeEl.textContent = formatTime(duration);
1194
+ this.totalTimeEl.dataset._extSet = '1';
1195
+ this._extDuration = duration;
1196
+ }
1197
+ this.drawWaveform?.();
1198
+ this.container.dispatchEvent(new CustomEvent('waveformplayer:timeupdate', {
1199
+ bubbles: true,
1200
+ detail: {player: this, currentTime, duration, progress: this.progress}
1201
+ }));
1202
+ if (this.options.onTimeUpdate) this.options.onTimeUpdate(this, currentTime, duration);
1203
+ }
1204
+
1014
1205
  /**
1015
1206
  * Toggle play/pause
1016
1207
  */
package/src/js/themes.js CHANGED
@@ -125,6 +125,13 @@ export const DEFAULT_OPTIONS = {
125
125
  samples: 200,
126
126
  preload: 'metadata',
127
127
 
128
+ // Audio mode — 'self' = player owns the <audio> element (default, current
129
+ // behavior). 'external' = player is a visualization-only surface; no audio
130
+ // element is created, play() dispatches `waveformplayer:request-play`
131
+ // instead of calling audio.play(), and setPlayingState/setProgress are
132
+ // expected to be driven by an external controller (e.g. WaveformBar).
133
+ audioMode: 'self',
134
+
128
135
  // Playback
129
136
  playbackRate: 1,
130
137
  showPlaybackSpeed: false,