@arraypress/waveform-bar 1.3.2 → 1.4.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/dist/waveform-bar.css +61 -1
- package/dist/waveform-bar.esm.js +450 -58
- package/dist/waveform-bar.js +450 -58
- package/dist/waveform-bar.min.css +1 -1
- package/dist/waveform-bar.min.js +6 -6
- package/package.json +8 -2
- package/src/css/waveform-bar.css +61 -1
- package/src/js/actions.js +2 -2
- package/src/js/core.js +529 -57
- package/src/js/dom.js +14 -1
- package/src/js/icons.js +3 -0
- package/src/js/utils.js +49 -4
package/src/js/core.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import {ICONS} from './icons.js';
|
|
7
|
-
import {extractTitle, escapeHtml, formatTime, parseTrackFromElement} from './utils.js';
|
|
7
|
+
import {extractTitle, escapeHtml, formatTime, parseTrackFromElement, isSafeHref} from './utils.js';
|
|
8
8
|
import {
|
|
9
9
|
saveQueueState,
|
|
10
10
|
restoreQueueState,
|
|
@@ -36,10 +36,18 @@ const DEFAULTS = {
|
|
|
36
36
|
maxMeta: 3,
|
|
37
37
|
defaultArtwork: null, // URL to fallback artwork image
|
|
38
38
|
theme: null, // 'dark', 'light', or null (dark by default)
|
|
39
|
+
wide: false, // true = content spans full width (lifts the 1400px cap)
|
|
40
|
+
maxWidth: null, // custom content max-width (CSS value), e.g. '1200px'; overrides `wide`
|
|
41
|
+
position: 'bottom', // 'bottom' (default) or 'top' — which edge the bar docks to
|
|
42
|
+
collapsible: false, // show a collapse button that shrinks the bar to a floating transport pill
|
|
43
|
+
waveform: true, // false = classic Spotify-style seek bar instead of the waveform
|
|
44
|
+
errorText: null, // custom "audio failed to load" message (null = player default)
|
|
45
|
+
share: false, // show a "copy share link" button (emits ?<shareParam>=<seconds>)
|
|
46
|
+
shareParam: 'wt', // URL query param for the shared timestamp (seconds)
|
|
39
47
|
waveformStyle: 'mirror',
|
|
40
48
|
waveformHeight: 32,
|
|
41
49
|
barWidth: 2,
|
|
42
|
-
barSpacing: 0
|
|
50
|
+
barSpacing: 2, // 2px gap between 2px bars — crisp, separated bars (0 = solid "blob")
|
|
43
51
|
waveformColor: null,
|
|
44
52
|
progressColor: null,
|
|
45
53
|
markerColor: 'rgba(255, 255, 255, 0.25)',
|
|
@@ -75,6 +83,15 @@ export class WaveformBar {
|
|
|
75
83
|
this._currentMarkerIndex = -1;
|
|
76
84
|
this.repeat = 'off'; // 'off', 'all', 'one'
|
|
77
85
|
|
|
86
|
+
// Load-race guard: bumped before every track load so async
|
|
87
|
+
// continuations (loadTrack().then / restore-seek timeout) can detect
|
|
88
|
+
// that a newer load superseded them and bail out.
|
|
89
|
+
this._loadSeq = 0;
|
|
90
|
+
this._restoreSeekTimeout = null;
|
|
91
|
+
|
|
92
|
+
// Map<url, Set<WaveformPlayer>> of external-mode visual surfaces.
|
|
93
|
+
this._externalPlayers = new Map();
|
|
94
|
+
|
|
78
95
|
// DOM refs
|
|
79
96
|
this.barEl = null;
|
|
80
97
|
this.queueEl = null;
|
|
@@ -109,10 +126,24 @@ export class WaveformBar {
|
|
|
109
126
|
if (this.isInitialized) this.destroy();
|
|
110
127
|
|
|
111
128
|
this.config = {...DEFAULTS, ...config};
|
|
112
|
-
|
|
129
|
+
|
|
130
|
+
// Normalize volume from config — guard NaN / out-of-range so a bad
|
|
131
|
+
// value can't mute the player or throw downstream.
|
|
132
|
+
const v = Number(this.config.volume);
|
|
133
|
+
this.volume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
|
|
134
|
+
|
|
135
|
+
// Shareable timestamp: parse the share link's `?<shareParam>=<seconds>`
|
|
136
|
+
// (time) plus the track identity (`?wid` = id, `?wu` = url fallback,
|
|
137
|
+
// `?wtitle`/`?wartist` for display). When the link names a track we
|
|
138
|
+
// cold-load it below; a bare `?<shareParam>=` with no track just seeks
|
|
139
|
+
// whatever loads first (applied once in the player onLoad hook).
|
|
140
|
+
this._shareTarget = this._readShareTarget();
|
|
141
|
+
this._shareSeek = (this._shareTarget && !this._shareTarget.id && !this._shareTarget.url)
|
|
142
|
+
? this._shareTarget.time
|
|
143
|
+
: null;
|
|
113
144
|
|
|
114
145
|
if (typeof window.WaveformPlayer === 'undefined') {
|
|
115
|
-
console.error('WaveformBar
|
|
146
|
+
console.error('[WaveformBar] WaveformPlayer is required.');
|
|
116
147
|
return this;
|
|
117
148
|
}
|
|
118
149
|
|
|
@@ -135,6 +166,15 @@ export class WaveformBar {
|
|
|
135
166
|
this._restoreState();
|
|
136
167
|
}
|
|
137
168
|
|
|
169
|
+
// A share link that names a track (`?wid`/`?wu`) cold-loads it here —
|
|
170
|
+
// AFTER restore, so the shared track wins over the visitor's own last
|
|
171
|
+
// session. It loads paused at the shared timestamp (autoplay is blocked
|
|
172
|
+
// on a cold page open anyway, and landing cued is the right UX).
|
|
173
|
+
if (this._shareTarget && (this._shareTarget.id || this._shareTarget.url)) {
|
|
174
|
+
const shared = this._resolveSharedTrack(this._shareTarget);
|
|
175
|
+
if (shared) this._loadSharedTrack(shared, this._shareTarget.time);
|
|
176
|
+
}
|
|
177
|
+
|
|
138
178
|
this.isInitialized = true;
|
|
139
179
|
|
|
140
180
|
// Save exact position when navigating away
|
|
@@ -153,6 +193,45 @@ export class WaveformBar {
|
|
|
153
193
|
this.player.destroy();
|
|
154
194
|
this.player = null;
|
|
155
195
|
}
|
|
196
|
+
if (this._docClickVolume) {
|
|
197
|
+
document.removeEventListener('click', this._docClickVolume);
|
|
198
|
+
this._docClickVolume = null;
|
|
199
|
+
}
|
|
200
|
+
if (this._docClickQueue) {
|
|
201
|
+
document.removeEventListener('click', this._docClickQueue);
|
|
202
|
+
this._docClickQueue = null;
|
|
203
|
+
}
|
|
204
|
+
// Single delegated trigger listener (replaces per-element binding).
|
|
205
|
+
if (this._docClickTriggers) {
|
|
206
|
+
document.removeEventListener('click', this._docClickTriggers);
|
|
207
|
+
this._docClickTriggers = null;
|
|
208
|
+
}
|
|
209
|
+
// External-mode WaveformPlayer document listeners — previously
|
|
210
|
+
// anonymous and never removed. Stored on `this` so they can be torn
|
|
211
|
+
// down and the bind flag reset for a clean re-init.
|
|
212
|
+
if (this._externalListenersBound) {
|
|
213
|
+
document.removeEventListener('waveformplayer:request-play', this._onExtRequestPlay);
|
|
214
|
+
document.removeEventListener('waveformplayer:request-pause', this._onExtRequestPause);
|
|
215
|
+
document.removeEventListener('waveformplayer:request-seek', this._onExtRequestSeek);
|
|
216
|
+
document.removeEventListener('waveformplayer:destroy', this._onExtDestroy);
|
|
217
|
+
this._onExtRequestPlay = null;
|
|
218
|
+
this._onExtRequestPause = null;
|
|
219
|
+
this._onExtRequestSeek = null;
|
|
220
|
+
this._onExtDestroy = null;
|
|
221
|
+
this._externalListenersBound = false;
|
|
222
|
+
}
|
|
223
|
+
this._externalPlayers = new Map();
|
|
224
|
+
|
|
225
|
+
// Cancel pending async work that could touch torn-down refs.
|
|
226
|
+
if (this._restoreSeekTimeout) {
|
|
227
|
+
clearTimeout(this._restoreSeekTimeout);
|
|
228
|
+
this._restoreSeekTimeout = null;
|
|
229
|
+
}
|
|
230
|
+
clearTimeout(this._shareFlashTimeout);
|
|
231
|
+
this._shareFlashTimeout = null;
|
|
232
|
+
// Invalidate any in-flight load continuation.
|
|
233
|
+
this._loadSeq++;
|
|
234
|
+
|
|
156
235
|
if (this.barEl) {
|
|
157
236
|
this.barEl.remove();
|
|
158
237
|
this.barEl = null;
|
|
@@ -170,7 +249,24 @@ export class WaveformBar {
|
|
|
170
249
|
this._beforeUnloadHandler = null;
|
|
171
250
|
}
|
|
172
251
|
|
|
173
|
-
|
|
252
|
+
// Null cached DOM refs so a stale ref can't be poked after teardown.
|
|
253
|
+
this.volumePopupEl = null;
|
|
254
|
+
this.queueBtnEl = null;
|
|
255
|
+
this.titleEl = null;
|
|
256
|
+
this.artistEl = null;
|
|
257
|
+
this.metaEl = null;
|
|
258
|
+
this.playBtnEl = null;
|
|
259
|
+
this.repeatBtnEl = null;
|
|
260
|
+
this.waveformContainer = null;
|
|
261
|
+
this.queueBodyEl = null;
|
|
262
|
+
this.queueCountEl = null;
|
|
263
|
+
this.muteBtnEl = null;
|
|
264
|
+
this.volumeSliderEl = null;
|
|
265
|
+
this.favBtnEl = null;
|
|
266
|
+
this.cartBtnEl = null;
|
|
267
|
+
this.timeCurrentEl = null;
|
|
268
|
+
this.timeTotalEl = null;
|
|
269
|
+
|
|
174
270
|
document.querySelectorAll('.wb-current,.wb-playing').forEach(el => el.classList.remove('wb-current', 'wb-playing'));
|
|
175
271
|
|
|
176
272
|
this.queue = [];
|
|
@@ -195,6 +291,14 @@ export class WaveformBar {
|
|
|
195
291
|
if (theme === 'light') this.barEl.classList.add('wb-light');
|
|
196
292
|
this._resolvedTheme = theme;
|
|
197
293
|
|
|
294
|
+
// Layout width: `maxWidth` wins; else `wide` lifts the cap to full width.
|
|
295
|
+
// Default (neither set) leaves the stylesheet's 1400px cap in place.
|
|
296
|
+
const maxWidth = this.config.maxWidth || (this.config.wide ? '100%' : null);
|
|
297
|
+
if (maxWidth) this.barEl.style.setProperty('--wb-max-width', maxWidth);
|
|
298
|
+
|
|
299
|
+
// Dock to the top edge instead of the bottom (flips slide direction).
|
|
300
|
+
if (this.config.position === 'top') this.barEl.classList.add('wb-top');
|
|
301
|
+
|
|
198
302
|
this.barEl.id = 'waveform-bar';
|
|
199
303
|
this.barEl.innerHTML = buildBarHTML(this.config);
|
|
200
304
|
document.body.appendChild(this.barEl);
|
|
@@ -206,20 +310,29 @@ export class WaveformBar {
|
|
|
206
310
|
this.playBtnEl = this.barEl.querySelector('.wb-play');
|
|
207
311
|
this.waveformContainer = this.barEl.querySelector('.wb-waveform-container');
|
|
208
312
|
this.queueBtnEl = this.barEl.querySelector('.wb-queue-btn');
|
|
313
|
+
this.shareBtnEl = this.barEl.querySelector('.wb-share');
|
|
209
314
|
this.muteBtnEl = this.barEl.querySelector('.wb-mute');
|
|
210
315
|
this.volumeSliderEl = this.barEl.querySelector('.wb-volume-slider');
|
|
211
316
|
this.favBtnEl = this.barEl.querySelector('.wb-fav');
|
|
212
317
|
this.cartBtnEl = this.barEl.querySelector('.wb-cart');
|
|
213
318
|
this.timeCurrentEl = this.barEl.querySelector('.wb-time-current');
|
|
214
319
|
this.timeTotalEl = this.barEl.querySelector('.wb-time-total');
|
|
320
|
+
this.collapseBtnEl = this.barEl.querySelector('.wb-collapse');
|
|
215
321
|
|
|
216
322
|
// Bind controls
|
|
217
323
|
this.playBtnEl.addEventListener('click', () => this.togglePlay());
|
|
218
324
|
|
|
325
|
+
// Collapse-to-pill toggle.
|
|
326
|
+
if (this.collapseBtnEl) {
|
|
327
|
+
this.collapseBtnEl.addEventListener('click', () => this.toggleCollapse());
|
|
328
|
+
if (this._readCollapsed()) this.collapse();
|
|
329
|
+
}
|
|
330
|
+
|
|
219
331
|
const prevBtn = this.barEl.querySelector('.wb-prev');
|
|
220
332
|
const nextBtn = this.barEl.querySelector('.wb-next');
|
|
221
333
|
if (prevBtn) prevBtn.addEventListener('click', () => this.previous());
|
|
222
334
|
if (nextBtn) nextBtn.addEventListener('click', () => this.next());
|
|
335
|
+
if (this.shareBtnEl) this.shareBtnEl.addEventListener('click', () => this._share());
|
|
223
336
|
|
|
224
337
|
this.repeatBtnEl = this.barEl.querySelector('.wb-repeat');
|
|
225
338
|
if (this.repeatBtnEl) {
|
|
@@ -259,13 +372,16 @@ export class WaveformBar {
|
|
|
259
372
|
});
|
|
260
373
|
}
|
|
261
374
|
|
|
262
|
-
// Close volume popup on outside click
|
|
263
|
-
|
|
375
|
+
// Close volume popup on outside click.
|
|
376
|
+
// Stored on `this` so destroy() can remove it — otherwise every
|
|
377
|
+
// re-init (init() calls destroy() first) leaks another listener.
|
|
378
|
+
this._docClickVolume = (e) => {
|
|
264
379
|
if (this.volumePopupEl?.classList.contains('wb-volume-open') &&
|
|
265
|
-
!this.barEl
|
|
380
|
+
!this.barEl?.querySelector('.wb-volume')?.contains(e.target)) {
|
|
266
381
|
this.closeVolumePopup();
|
|
267
382
|
}
|
|
268
|
-
}
|
|
383
|
+
};
|
|
384
|
+
document.addEventListener('click', this._docClickVolume);
|
|
269
385
|
|
|
270
386
|
if (this.favBtnEl) this.favBtnEl.addEventListener('click', () => this.toggleFavorite());
|
|
271
387
|
if (this.cartBtnEl) this.cartBtnEl.addEventListener('click', () => this.addToCart());
|
|
@@ -274,7 +390,9 @@ export class WaveformBar {
|
|
|
274
390
|
if (this.config.showTrackLink) {
|
|
275
391
|
this.barEl.querySelector('.wb-track').addEventListener('click', () => {
|
|
276
392
|
const t = this.getCurrentTrack();
|
|
277
|
-
|
|
393
|
+
// Guard the scheme — a `data-wb-link="javascript:…"` value
|
|
394
|
+
// would otherwise execute on navigation (open-redirect / XSS).
|
|
395
|
+
if (t && t.link && isSafeHref(t.link)) window.location.href = t.link;
|
|
278
396
|
});
|
|
279
397
|
}
|
|
280
398
|
}
|
|
@@ -291,21 +409,28 @@ export class WaveformBar {
|
|
|
291
409
|
|
|
292
410
|
this.queueEl.querySelector('.wb-queue-clear').addEventListener('click', () => this.clearQueue());
|
|
293
411
|
|
|
294
|
-
|
|
295
|
-
|
|
412
|
+
// Stored on `this` so destroy() can remove it — otherwise every
|
|
413
|
+
// re-init leaks another document listener.
|
|
414
|
+
this._docClickQueue = (e) => {
|
|
415
|
+
if (this.queueOpen && !this.queueEl?.contains(e.target) && !this.queueBtnEl?.contains(e.target)) {
|
|
296
416
|
this.closeQueuePanel();
|
|
297
417
|
}
|
|
298
|
-
}
|
|
418
|
+
};
|
|
419
|
+
document.addEventListener('click', this._docClickQueue);
|
|
299
420
|
}
|
|
300
421
|
|
|
301
422
|
_initPlayer() {
|
|
302
423
|
const opts = {
|
|
303
424
|
showControls: false,
|
|
304
425
|
showInfo: false,
|
|
305
|
-
|
|
426
|
+
// Classic mode reuses the player's own built-in 'seekbar' style —
|
|
427
|
+
// a simple rounded progress bar (no waveform), with the player's
|
|
428
|
+
// native click-to-seek. No custom seek-bar DOM needed.
|
|
429
|
+
waveformStyle: this.config.waveform === false ? 'seekbar' : this.config.waveformStyle,
|
|
306
430
|
height: this.config.waveformHeight,
|
|
307
431
|
barWidth: this.config.barWidth,
|
|
308
432
|
barSpacing: this.config.barSpacing,
|
|
433
|
+
errorText: this.config.errorText, // null -> player uses its own default
|
|
309
434
|
singlePlay: false,
|
|
310
435
|
onPlay: () => {
|
|
311
436
|
this.isPlaying = true;
|
|
@@ -356,6 +481,26 @@ export class WaveformBar {
|
|
|
356
481
|
this._loadCurrentTrack();
|
|
357
482
|
}
|
|
358
483
|
},
|
|
484
|
+
onError: () => {
|
|
485
|
+
// A track failed to load/decode (bad URL, 404, codec). Without
|
|
486
|
+
// this the queue dead-stops on the broken entry. Reset play
|
|
487
|
+
// state, notify, and — when continuous — skip to the next
|
|
488
|
+
// entry so one dead track doesn't halt the whole queue.
|
|
489
|
+
this.isPlaying = false;
|
|
490
|
+
this._updatePlayButton();
|
|
491
|
+
this._syncPageState();
|
|
492
|
+
this._pumpExternalPlayState(false);
|
|
493
|
+
|
|
494
|
+
const track = this.getCurrentTrack();
|
|
495
|
+
this._emit('error', {track});
|
|
496
|
+
|
|
497
|
+
// Auto-advance like onEnd (but never wrap via repeat-all —
|
|
498
|
+
// that risks an infinite loop if the first track is also dead).
|
|
499
|
+
if (this.config.continuous && this.currentIndex < this.queue.length - 1) {
|
|
500
|
+
this.currentIndex++;
|
|
501
|
+
this._loadCurrentTrack();
|
|
502
|
+
}
|
|
503
|
+
},
|
|
359
504
|
onTimeUpdate: (currentTime, duration) => {
|
|
360
505
|
this._lastPosition = currentTime;
|
|
361
506
|
if (this.timeCurrentEl) this.timeCurrentEl.textContent = formatTime(currentTime);
|
|
@@ -377,7 +522,14 @@ export class WaveformBar {
|
|
|
377
522
|
this._checkMarkerBoundary(currentTime);
|
|
378
523
|
}
|
|
379
524
|
},
|
|
380
|
-
onLoad:
|
|
525
|
+
onLoad: () => {
|
|
526
|
+
// Apply a shareable-timestamp seek (?<shareParam>=) once, on the
|
|
527
|
+
// first track load after a fresh page open.
|
|
528
|
+
if (this._shareSeek != null && this.player) {
|
|
529
|
+
this.player.seekTo(this._shareSeek);
|
|
530
|
+
this._shareSeek = null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
381
533
|
};
|
|
382
534
|
|
|
383
535
|
if (this.config.waveformColor) opts.waveformColor = this.config.waveformColor;
|
|
@@ -392,26 +544,34 @@ export class WaveformBar {
|
|
|
392
544
|
// =====================================================================
|
|
393
545
|
|
|
394
546
|
_bindTriggers() {
|
|
395
|
-
document
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
547
|
+
// ONE delegated document listener instead of per-element binding.
|
|
548
|
+
// Per-element listeners leaked on every re-init (init() → destroy()
|
|
549
|
+
// only cleared a flag, never removed the handlers), leaving each row
|
|
550
|
+
// with stacked handlers that toggled each other so clicks did nothing.
|
|
551
|
+
// Delegation also covers late-mounted triggers for free, so the
|
|
552
|
+
// MutationObserver no longer needs to re-bind anything.
|
|
553
|
+
if (!this._docClickTriggers) {
|
|
554
|
+
this._docClickTriggers = (e) => {
|
|
555
|
+
// Queue first — a queue trigger nested in (or alongside) a
|
|
556
|
+
// play trigger must enqueue, not play. This mirrors the old
|
|
557
|
+
// per-element handler's stopPropagation() semantics.
|
|
558
|
+
const queueEl = e.target?.closest?.('[data-wb-queue]');
|
|
559
|
+
if (queueEl) {
|
|
560
|
+
e.preventDefault();
|
|
561
|
+
const track = parseTrackFromElement(queueEl);
|
|
562
|
+
if (track) this.addToQueue(track);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
404
565
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
});
|
|
566
|
+
const playEl = e.target?.closest?.('[data-wb-play]');
|
|
567
|
+
if (playEl) {
|
|
568
|
+
e.preventDefault();
|
|
569
|
+
const track = parseTrackFromElement(playEl);
|
|
570
|
+
if (track) this.play(track);
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
document.addEventListener('click', this._docClickTriggers);
|
|
574
|
+
}
|
|
415
575
|
|
|
416
576
|
// External-mode WaveformPlayer instances on the page act as
|
|
417
577
|
// visualization surfaces controlled by this bar — see the
|
|
@@ -435,18 +595,20 @@ export class WaveformBar {
|
|
|
435
595
|
* @private
|
|
436
596
|
*/
|
|
437
597
|
_attachExternalPlayers() {
|
|
438
|
-
// Document-level listeners only bind once.
|
|
598
|
+
// Document-level listeners only bind once. Stored on `this` so
|
|
599
|
+
// destroy() can remove them (they were previously anonymous and
|
|
600
|
+
// leaked across re-inits).
|
|
439
601
|
if (!this._externalListenersBound) {
|
|
440
602
|
this._externalListenersBound = true;
|
|
441
603
|
|
|
442
|
-
|
|
604
|
+
this._onExtRequestPlay = (e) => {
|
|
443
605
|
const t = e.detail;
|
|
444
606
|
if (!t || !t.url) return;
|
|
445
607
|
e.preventDefault();
|
|
446
608
|
this.play(t);
|
|
447
|
-
}
|
|
609
|
+
};
|
|
448
610
|
|
|
449
|
-
|
|
611
|
+
this._onExtRequestPause = (e) => {
|
|
450
612
|
const t = e.detail;
|
|
451
613
|
if (!t || !t.url) return;
|
|
452
614
|
// Only honour pause when this is the currently-playing
|
|
@@ -456,9 +618,9 @@ export class WaveformBar {
|
|
|
456
618
|
e.preventDefault();
|
|
457
619
|
if (this.isPlaying) this.togglePlay();
|
|
458
620
|
}
|
|
459
|
-
}
|
|
621
|
+
};
|
|
460
622
|
|
|
461
|
-
|
|
623
|
+
this._onExtRequestSeek = (e) => {
|
|
462
624
|
const t = e.detail;
|
|
463
625
|
if (!t || !t.url || typeof t.percent !== 'number') return;
|
|
464
626
|
const current = this.getCurrentTrack();
|
|
@@ -466,7 +628,20 @@ export class WaveformBar {
|
|
|
466
628
|
e.preventDefault();
|
|
467
629
|
this.player.seekToPercent(t.percent);
|
|
468
630
|
}
|
|
469
|
-
}
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
// Prune a destroyed external player from the URL → players map
|
|
634
|
+
// so we don't keep pumping state into a torn-down instance.
|
|
635
|
+
this._onExtDestroy = (e) => {
|
|
636
|
+
const inst = e.detail && e.detail.player;
|
|
637
|
+
if (!inst || !this._externalPlayers) return;
|
|
638
|
+
this._externalPlayers.forEach((set) => set.delete(inst));
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
document.addEventListener('waveformplayer:request-play', this._onExtRequestPlay);
|
|
642
|
+
document.addEventListener('waveformplayer:request-pause', this._onExtRequestPause);
|
|
643
|
+
document.addEventListener('waveformplayer:request-seek', this._onExtRequestSeek);
|
|
644
|
+
document.addEventListener('waveformplayer:destroy', this._onExtDestroy);
|
|
470
645
|
}
|
|
471
646
|
|
|
472
647
|
// Rebuild the URL → players map from scratch each pass — cheap
|
|
@@ -559,8 +734,12 @@ export class WaveformBar {
|
|
|
559
734
|
|
|
560
735
|
_observeDOM() {
|
|
561
736
|
if (typeof MutationObserver === 'undefined') return;
|
|
737
|
+
// Triggers are handled by the single delegated listener, so the
|
|
738
|
+
// observer only needs to (re)discover late-mounted external-mode
|
|
739
|
+
// players and re-sync page state.
|
|
740
|
+
// TODO(harden): debounce this callback.
|
|
562
741
|
this._observer = new MutationObserver(() => {
|
|
563
|
-
this.
|
|
742
|
+
this._attachExternalPlayers();
|
|
564
743
|
this._syncPageState();
|
|
565
744
|
});
|
|
566
745
|
this._observer.observe(document.body, {childList: true, subtree: true});
|
|
@@ -735,6 +914,9 @@ export class WaveformBar {
|
|
|
735
914
|
this.isMuted = true;
|
|
736
915
|
if (this.player) this.player.setVolume(0);
|
|
737
916
|
this._updateVolumeUI();
|
|
917
|
+
// Persist the muted flag — the unmute branch routes through
|
|
918
|
+
// setVolume() (which saves), so mute must save too.
|
|
919
|
+
saveVolume(this.config.storageKey, this.volume, this.isMuted, this._volumeBeforeMute);
|
|
738
920
|
}
|
|
739
921
|
return this;
|
|
740
922
|
}
|
|
@@ -782,7 +964,9 @@ export class WaveformBar {
|
|
|
782
964
|
// Visual feedback on bar button
|
|
783
965
|
if (this.cartBtnEl) {
|
|
784
966
|
this.cartBtnEl.classList.add('wb-action-done');
|
|
785
|
-
setTimeout(() =>
|
|
967
|
+
setTimeout(() => {
|
|
968
|
+
if (this.cartBtnEl) this.cartBtnEl.classList.remove('wb-action-done');
|
|
969
|
+
}, 1500);
|
|
786
970
|
}
|
|
787
971
|
|
|
788
972
|
// Sync data attribute back to page triggers
|
|
@@ -973,10 +1157,285 @@ export class WaveformBar {
|
|
|
973
1157
|
// Internal: Loading & Display
|
|
974
1158
|
// =====================================================================
|
|
975
1159
|
|
|
1160
|
+
/**
|
|
1161
|
+
* Parse a share link from the URL: the timestamp (`?<shareParam>=`, seconds)
|
|
1162
|
+
* plus the track identity needed to load it cold — `?wid` (id, preferred),
|
|
1163
|
+
* `?wu` (url, the works-anywhere fallback), and `?wtitle`/`?wartist` for
|
|
1164
|
+
* display before metadata arrives. Returns null when no share params are
|
|
1165
|
+
* present. An unsafe `?wu` (javascript:/data: etc.) is dropped, not loaded.
|
|
1166
|
+
* @returns {{time:number, id:string|null, url:string|null, title:string|null, artist:string|null}|null}
|
|
1167
|
+
* @private
|
|
1168
|
+
*/
|
|
1169
|
+
_readShareTarget() {
|
|
1170
|
+
let q;
|
|
1171
|
+
try {
|
|
1172
|
+
q = new URLSearchParams(window.location.search);
|
|
1173
|
+
} catch (e) {
|
|
1174
|
+
return null;
|
|
1175
|
+
}
|
|
1176
|
+
const rawTime = q.get(this.config.shareParam);
|
|
1177
|
+
const id = q.get('wid');
|
|
1178
|
+
const rawUrl = q.get('wu');
|
|
1179
|
+
if (rawTime == null && id == null && rawUrl == null) return null;
|
|
1180
|
+
|
|
1181
|
+
let time = 0;
|
|
1182
|
+
if (rawTime != null) {
|
|
1183
|
+
const t = Number(rawTime);
|
|
1184
|
+
if (Number.isFinite(t) && t >= 0) time = t;
|
|
1185
|
+
}
|
|
1186
|
+
// Only honour an embedded url that's a safe http(s)/relative target —
|
|
1187
|
+
// the value is attacker-controllable, so never let it be javascript:.
|
|
1188
|
+
const url = (rawUrl && isSafeHref(rawUrl)) ? rawUrl : null;
|
|
1189
|
+
|
|
1190
|
+
return {time, id: id || null, url, title: q.get('wtitle'), artist: q.get('wartist')};
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Resolve a share target to a loadable track. Prefers an on-page trigger
|
|
1195
|
+
* (matched by `data-wb-id`, then by url) so the cold load inherits the
|
|
1196
|
+
* page's pre-generated peaks, markers, and favorite/cart state; falls back
|
|
1197
|
+
* to a minimal track built from the embedded url + title/artist so the link
|
|
1198
|
+
* still works on a page that doesn't contain the track.
|
|
1199
|
+
* @param {{id:string|null, url:string|null, title:string|null, artist:string|null}} target
|
|
1200
|
+
* @returns {Object|null}
|
|
1201
|
+
* @private
|
|
1202
|
+
*/
|
|
1203
|
+
_resolveSharedTrack(target) {
|
|
1204
|
+
const triggers = document.querySelectorAll('[data-wb-play], [data-wb-queue]');
|
|
1205
|
+
|
|
1206
|
+
// 1. By id (clean, short links). Skips when no id was shared.
|
|
1207
|
+
if (target.id) {
|
|
1208
|
+
for (const el of triggers) {
|
|
1209
|
+
if (el.dataset.wbId === target.id || el.dataset.id === target.id) {
|
|
1210
|
+
const t = parseTrackFromElement(el);
|
|
1211
|
+
if (t) return t;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// 2. By url — match an on-page trigger to inherit its peaks/state...
|
|
1217
|
+
if (target.url) {
|
|
1218
|
+
for (const el of triggers) {
|
|
1219
|
+
if (el.dataset.wbUrl === target.url || el.dataset.url === target.url) {
|
|
1220
|
+
const t = parseTrackFromElement(el);
|
|
1221
|
+
if (t) return t;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
// 3. ...else build a minimal track straight from the link (cross-page).
|
|
1225
|
+
return {
|
|
1226
|
+
url: target.url,
|
|
1227
|
+
id: target.id || target.url,
|
|
1228
|
+
title: target.title || extractTitle(target.url),
|
|
1229
|
+
artist: target.artist || ''
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Cold-load a share-target track at a timestamp, paused. Mirrors the
|
|
1238
|
+
* restore path (loadTrack with autoplay:false + a `_loadSeq`-guarded,
|
|
1239
|
+
* delayed seek) so a later user action cleanly supersedes it.
|
|
1240
|
+
* @param {Object} track
|
|
1241
|
+
* @param {number} time - seconds to seek to once loaded
|
|
1242
|
+
* @private
|
|
1243
|
+
*/
|
|
1244
|
+
_loadSharedTrack(track, time) {
|
|
1245
|
+
if (!track || !track.url || !this.player) return;
|
|
1246
|
+
|
|
1247
|
+
// Make it the current queue entry (dedup by url, like play()).
|
|
1248
|
+
const existing = this.queue.findIndex(t => t.url === track.url);
|
|
1249
|
+
if (existing >= 0) {
|
|
1250
|
+
this.queue[existing] = {...this.queue[existing], ...track};
|
|
1251
|
+
this.currentIndex = existing;
|
|
1252
|
+
} else {
|
|
1253
|
+
this.queue.push(track);
|
|
1254
|
+
this.currentIndex = this.queue.length - 1;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
this.show();
|
|
1258
|
+
this._updateTrackDisplay(track);
|
|
1259
|
+
this._updateFavoriteUI();
|
|
1260
|
+
this._updateNavButtons();
|
|
1261
|
+
|
|
1262
|
+
const loadOpts = {autoplay: false};
|
|
1263
|
+
if (track.waveform) loadOpts.waveform = track.waveform;
|
|
1264
|
+
if (track.markers && track.markers.length) {
|
|
1265
|
+
const defaultColor = this.config.markerColor;
|
|
1266
|
+
loadOpts.markers = track.markers.map(m => ({...m, color: m.color || defaultColor}));
|
|
1267
|
+
this._activeMarkers = track.markers;
|
|
1268
|
+
} else {
|
|
1269
|
+
loadOpts.markers = [];
|
|
1270
|
+
this._activeMarkers = null;
|
|
1271
|
+
}
|
|
1272
|
+
this._currentMarkerIndex = -1;
|
|
1273
|
+
|
|
1274
|
+
const seq = ++this._loadSeq;
|
|
1275
|
+
if (this._restoreSeekTimeout) {
|
|
1276
|
+
clearTimeout(this._restoreSeekTimeout);
|
|
1277
|
+
this._restoreSeekTimeout = null;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
this.player.loadTrack(track.url, track.title, track.artist, loadOpts).then(() => {
|
|
1281
|
+
if (this._loadSeq !== seq) return;
|
|
1282
|
+
if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
|
|
1283
|
+
// Seek slightly after load — the audio element needs to be ready
|
|
1284
|
+
// for the seek to stick (same 100ms guard the restore path uses).
|
|
1285
|
+
if (time > 0) {
|
|
1286
|
+
this._restoreSeekTimeout = setTimeout(() => {
|
|
1287
|
+
this._restoreSeekTimeout = null;
|
|
1288
|
+
if (this._loadSeq !== seq) return;
|
|
1289
|
+
if (this.player) {
|
|
1290
|
+
this.player.seekTo(time);
|
|
1291
|
+
this._lastPosition = time;
|
|
1292
|
+
}
|
|
1293
|
+
}, 100);
|
|
1294
|
+
}
|
|
1295
|
+
}).catch(() => {});
|
|
1296
|
+
|
|
1297
|
+
this._renderQueue();
|
|
1298
|
+
this._syncPageState();
|
|
1299
|
+
this._saveState();
|
|
1300
|
+
this._updateNavButtons();
|
|
1301
|
+
this._emit('trackchange', {track, index: this.currentIndex});
|
|
1302
|
+
if (this.config.onTrackChange) this.config.onTrackChange(track, this.currentIndex);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Copy a shareable link to the current track at the current position, use
|
|
1307
|
+
* the native share sheet when available, and emit `waveformbar:share`. The
|
|
1308
|
+
* link carries both the timestamp AND the track identity so a cold open
|
|
1309
|
+
* loads the right audio: `?<shareParam>=<seconds>` plus `wid` (id, when the
|
|
1310
|
+
* track has a real one), `wu` (url — the works-anywhere fallback), and
|
|
1311
|
+
* `wtitle`/`wartist` for display before metadata loads.
|
|
1312
|
+
* @private
|
|
1313
|
+
*/
|
|
1314
|
+
_share() {
|
|
1315
|
+
const track = this.getCurrentTrack();
|
|
1316
|
+
const cur = this.player && this.player.audio ? this.player.audio.currentTime : 0;
|
|
1317
|
+
const seconds = Math.max(0, Math.floor(cur || 0));
|
|
1318
|
+
let link;
|
|
1319
|
+
try {
|
|
1320
|
+
const url = new URL(window.location.href);
|
|
1321
|
+
const p = url.searchParams;
|
|
1322
|
+
p.set(this.config.shareParam, String(seconds));
|
|
1323
|
+
if (track) {
|
|
1324
|
+
// id falls back to url internally, so only emit wid when it's a
|
|
1325
|
+
// real, distinct identifier (clean per-track-page links).
|
|
1326
|
+
if (track.id && track.id !== track.url) p.set('wid', track.id);
|
|
1327
|
+
if (track.url) p.set('wu', track.url);
|
|
1328
|
+
if (track.title) p.set('wtitle', track.title);
|
|
1329
|
+
if (track.artist) p.set('wartist', track.artist);
|
|
1330
|
+
}
|
|
1331
|
+
link = url.toString();
|
|
1332
|
+
} catch (e) {
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
1336
|
+
navigator.clipboard.writeText(link).catch(() => {});
|
|
1337
|
+
}
|
|
1338
|
+
if (navigator.share) {
|
|
1339
|
+
navigator.share({title: (track && track.title) || undefined, url: link}).catch(() => {});
|
|
1340
|
+
}
|
|
1341
|
+
this._flashShareCopied();
|
|
1342
|
+
this._emit('share', {url: link, time: seconds, track});
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Briefly flag the share button as "copied" for visual feedback.
|
|
1347
|
+
* @private
|
|
1348
|
+
*/
|
|
1349
|
+
_flashShareCopied() {
|
|
1350
|
+
if (!this.shareBtnEl) return;
|
|
1351
|
+
this.shareBtnEl.classList.add('wb-copied');
|
|
1352
|
+
this.shareBtnEl.setAttribute('title', 'Link copied!');
|
|
1353
|
+
clearTimeout(this._shareFlashTimeout);
|
|
1354
|
+
this._shareFlashTimeout = setTimeout(() => {
|
|
1355
|
+
if (this.shareBtnEl) {
|
|
1356
|
+
this.shareBtnEl.classList.remove('wb-copied');
|
|
1357
|
+
this.shareBtnEl.setAttribute('title', 'Copy share link');
|
|
1358
|
+
}
|
|
1359
|
+
}, 1500);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Collapse the bar to a small floating pill (artwork + play + expand).
|
|
1364
|
+
* @returns {WaveformBar}
|
|
1365
|
+
*/
|
|
1366
|
+
collapse() {
|
|
1367
|
+
this.isCollapsed = true;
|
|
1368
|
+
if (this.barEl) this.barEl.classList.add('wb-collapsed');
|
|
1369
|
+
this._updateCollapseButton();
|
|
1370
|
+
this._saveCollapsed();
|
|
1371
|
+
this._emit('collapse', {collapsed: true});
|
|
1372
|
+
return this;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Restore the bar from its collapsed pill back to the full bar.
|
|
1377
|
+
* @returns {WaveformBar}
|
|
1378
|
+
*/
|
|
1379
|
+
expand() {
|
|
1380
|
+
this.isCollapsed = false;
|
|
1381
|
+
if (this.barEl) this.barEl.classList.remove('wb-collapsed');
|
|
1382
|
+
this._updateCollapseButton();
|
|
1383
|
+
this._saveCollapsed();
|
|
1384
|
+
this._emit('collapse', {collapsed: false});
|
|
1385
|
+
return this;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Toggle the collapsed pill state.
|
|
1390
|
+
* @returns {WaveformBar}
|
|
1391
|
+
*/
|
|
1392
|
+
toggleCollapse() {
|
|
1393
|
+
return this.isCollapsed ? this.expand() : this.collapse();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Swap the collapse button's icon + labels for the current state.
|
|
1398
|
+
* @private
|
|
1399
|
+
*/
|
|
1400
|
+
_updateCollapseButton() {
|
|
1401
|
+
if (!this.collapseBtnEl) return;
|
|
1402
|
+
this.collapseBtnEl.innerHTML = this.isCollapsed ? ICONS.expand : ICONS.collapse;
|
|
1403
|
+
const label = this.isCollapsed ? 'Expand' : 'Collapse';
|
|
1404
|
+
this.collapseBtnEl.setAttribute('aria-label', label);
|
|
1405
|
+
this.collapseBtnEl.setAttribute('title', label);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** Persist the collapsed state (session-scoped) when persistence is on. @private */
|
|
1409
|
+
_saveCollapsed() {
|
|
1410
|
+
if (!this.config.persist) return;
|
|
1411
|
+
try {
|
|
1412
|
+
sessionStorage.setItem(this.config.storageKey + '-collapsed', this.isCollapsed ? '1' : '0');
|
|
1413
|
+
} catch (e) {}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** Read the persisted collapsed state. @returns {boolean} @private */
|
|
1417
|
+
_readCollapsed() {
|
|
1418
|
+
if (!this.config.persist) return false;
|
|
1419
|
+
try {
|
|
1420
|
+
return sessionStorage.getItem(this.config.storageKey + '-collapsed') === '1';
|
|
1421
|
+
} catch (e) {
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
976
1426
|
_loadCurrentTrack() {
|
|
977
1427
|
const track = this.getCurrentTrack();
|
|
978
1428
|
if (!track || !this.player) return;
|
|
979
1429
|
|
|
1430
|
+
// Bump the load token and cancel any pending restore-seek so a
|
|
1431
|
+
// stale async continuation (from a prior load) can't apply to this
|
|
1432
|
+
// newer track under rapid switching.
|
|
1433
|
+
this._loadSeq++;
|
|
1434
|
+
if (this._restoreSeekTimeout) {
|
|
1435
|
+
clearTimeout(this._restoreSeekTimeout);
|
|
1436
|
+
this._restoreSeekTimeout = null;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
980
1439
|
// Reset any previously-current external player so its UI flips
|
|
981
1440
|
// back to "paused" while the new track loads. The new track's
|
|
982
1441
|
// onPlay callback will set the matching external to playing
|
|
@@ -1188,7 +1647,7 @@ export class WaveformBar {
|
|
|
1188
1647
|
// Update artwork if provided
|
|
1189
1648
|
if (marker.artwork) {
|
|
1190
1649
|
const artworkEl = this.barEl.querySelector('.wb-artwork');
|
|
1191
|
-
if (artworkEl) artworkEl.innerHTML = `<img src="${marker.artwork}" alt="${marker.title || ''}" />`;
|
|
1650
|
+
if (artworkEl) artworkEl.innerHTML = `<img src="${escapeHtml(marker.artwork)}" alt="${escapeHtml(marker.title || '')}" />`;
|
|
1192
1651
|
}
|
|
1193
1652
|
|
|
1194
1653
|
// Update meta tags if bpm/key provided
|
|
@@ -1411,30 +1870,38 @@ export class WaveformBar {
|
|
|
1411
1870
|
this._updateFavoriteUI();
|
|
1412
1871
|
this._updateNavButtons();
|
|
1413
1872
|
|
|
1414
|
-
// Use
|
|
1415
|
-
//
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
this.player.options.title = track.title || '';
|
|
1421
|
-
this.player.options.subtitle = track.artist || '';
|
|
1873
|
+
// Use the public loadTrack() API with { autoplay: false } (core
|
|
1874
|
+
// v1.8.0+) instead of poking player.options.* + load(). This avoids
|
|
1875
|
+
// loadTrack's forced auto-play while still routing through the
|
|
1876
|
+
// supported API — we handle seek and resume manually below.
|
|
1877
|
+
const loadOpts = {autoplay: false};
|
|
1878
|
+
if (track.waveform) loadOpts.waveform = track.waveform;
|
|
1422
1879
|
|
|
1423
1880
|
// Pass markers to the player and set up DJ mode
|
|
1424
1881
|
if (track.markers && track.markers.length) {
|
|
1425
1882
|
const defaultColor = this.config.markerColor;
|
|
1426
|
-
|
|
1883
|
+
loadOpts.markers = track.markers.map(m => ({
|
|
1427
1884
|
...m,
|
|
1428
1885
|
color: m.color || defaultColor
|
|
1429
1886
|
}));
|
|
1430
1887
|
this._activeMarkers = track.markers;
|
|
1431
1888
|
} else {
|
|
1432
|
-
|
|
1889
|
+
loadOpts.markers = [];
|
|
1433
1890
|
this._activeMarkers = null;
|
|
1434
1891
|
}
|
|
1435
1892
|
this._currentMarkerIndex = -1;
|
|
1436
1893
|
|
|
1437
|
-
|
|
1894
|
+
// Capture the load token so the async continuation below bails out if
|
|
1895
|
+
// a newer load (user clicked another track, or destroy()) supersedes
|
|
1896
|
+
// this restore before its promise / seek-timeout fires.
|
|
1897
|
+
const seq = ++this._loadSeq;
|
|
1898
|
+
if (this._restoreSeekTimeout) {
|
|
1899
|
+
clearTimeout(this._restoreSeekTimeout);
|
|
1900
|
+
this._restoreSeekTimeout = null;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
this.player.loadTrack(track.url, track.title, track.artist, loadOpts).then(() => {
|
|
1904
|
+
if (this._loadSeq !== seq) return;
|
|
1438
1905
|
if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
|
|
1439
1906
|
|
|
1440
1907
|
if (state.isPlaying && this.config.autoResume) {
|
|
@@ -1457,7 +1924,9 @@ export class WaveformBar {
|
|
|
1457
1924
|
// Seek after play — the audio element needs to be in a playing
|
|
1458
1925
|
// or ready state for seek to stick reliably
|
|
1459
1926
|
if (state.position > 0) {
|
|
1460
|
-
setTimeout(() => {
|
|
1927
|
+
this._restoreSeekTimeout = setTimeout(() => {
|
|
1928
|
+
this._restoreSeekTimeout = null;
|
|
1929
|
+
if (this._loadSeq !== seq) return;
|
|
1461
1930
|
if (this.player) {
|
|
1462
1931
|
this.player.seekTo(state.position);
|
|
1463
1932
|
this._lastPosition = state.position;
|
|
@@ -1474,7 +1943,10 @@ export class WaveformBar {
|
|
|
1474
1943
|
_restoreVolume() {
|
|
1475
1944
|
const data = restoreVolume(this.config.storageKey);
|
|
1476
1945
|
if (!data) return;
|
|
1477
|
-
|
|
1946
|
+
// Normalize persisted volume — a corrupted store could otherwise
|
|
1947
|
+
// carry NaN / out-of-range and mute or break the player.
|
|
1948
|
+
const v = Number(data.volume);
|
|
1949
|
+
this.volume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
|
|
1478
1950
|
this.isMuted = data.muted;
|
|
1479
1951
|
this._volumeBeforeMute = data.volumeBeforeMute;
|
|
1480
1952
|
if (this.player) this.player.setVolume(this.isMuted ? 0 : this.volume);
|