@arraypress/waveform-bar 1.0.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/LICENSE +21 -0
- package/README.md +732 -0
- package/dist/waveform-bar.css +943 -0
- package/dist/waveform-bar.esm.js +1389 -0
- package/dist/waveform-bar.js +1387 -0
- package/dist/waveform-bar.min.css +1 -0
- package/dist/waveform-bar.min.js +51 -0
- package/package.json +60 -0
- package/src/css/waveform-bar.css +943 -0
- package/src/js/actions.js +35 -0
- package/src/js/core.js +1271 -0
- package/src/js/dom.js +84 -0
- package/src/js/icons.js +24 -0
- package/src/js/index.js +20 -0
- package/src/js/queue.js +113 -0
- package/src/js/storage.js +92 -0
- package/src/js/utils.js +74 -0
package/src/js/dom.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module dom
|
|
3
|
+
* @description DOM creation for WaveformBar
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {ICONS} from './icons.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the bar's inner HTML based on config
|
|
10
|
+
* @param {Object} config
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function buildBarHTML(config) {
|
|
14
|
+
// --- Left zone: controls + track info ---
|
|
15
|
+
let left = '<div class="wb-left">';
|
|
16
|
+
|
|
17
|
+
left += '<div class="wb-controls">';
|
|
18
|
+
if (config.showPrevNext) {
|
|
19
|
+
left += `<button class="wb-btn wb-prev" aria-label="Previous" title="Previous">${ICONS.prev}</button>`;
|
|
20
|
+
}
|
|
21
|
+
left += `<button class="wb-btn wb-play" aria-label="Play/Pause" title="Play">
|
|
22
|
+
<span class="wb-icon-play">${ICONS.play}</span>
|
|
23
|
+
<span class="wb-icon-pause" style="display:none">${ICONS.pause}</span>
|
|
24
|
+
</button>`;
|
|
25
|
+
if (config.showPrevNext) {
|
|
26
|
+
left += `<button class="wb-btn wb-next" aria-label="Next" title="Next">${ICONS.next}</button>`;
|
|
27
|
+
}
|
|
28
|
+
if (config.showRepeat) {
|
|
29
|
+
left += `<button class="wb-btn wb-btn-sm wb-repeat" aria-label="Repeat" title="Repeat: Off">${ICONS.repeatOff}</button>`;
|
|
30
|
+
}
|
|
31
|
+
left += '</div>';
|
|
32
|
+
|
|
33
|
+
left += `<div class="wb-track">
|
|
34
|
+
<div class="wb-artwork">${ICONS.music}</div>
|
|
35
|
+
<div class="wb-track-text">
|
|
36
|
+
<div class="wb-title">No track selected</div>
|
|
37
|
+
<div class="wb-artist">—</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>`;
|
|
40
|
+
left += '</div>';
|
|
41
|
+
|
|
42
|
+
// --- Centre zone: waveform + time ---
|
|
43
|
+
const centre = `<div class="wb-centre">
|
|
44
|
+
<div class="wb-waveform-container"></div>
|
|
45
|
+
<div class="wb-time"><span class="wb-time-current">0:00</span> / <span class="wb-time-total">0:00</span></div>
|
|
46
|
+
</div>`;
|
|
47
|
+
|
|
48
|
+
// --- Right zone: meta + actions + volume + queue ---
|
|
49
|
+
let right = '<div class="wb-right">';
|
|
50
|
+
|
|
51
|
+
if (config.showMeta) {
|
|
52
|
+
right += '<div class="wb-meta"></div>';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (config.actions) {
|
|
56
|
+
right += '<div class="wb-actions">';
|
|
57
|
+
if (config.actions.favorite) {
|
|
58
|
+
right += `<button class="wb-btn wb-btn-sm wb-fav" aria-label="Favorite" title="Favorite">${ICONS.heart}</button>`;
|
|
59
|
+
}
|
|
60
|
+
if (config.actions.cart) {
|
|
61
|
+
right += `<button class="wb-btn wb-btn-sm wb-cart" aria-label="Add to cart" title="Add to Cart">${ICONS.cart}</button>`;
|
|
62
|
+
}
|
|
63
|
+
right += '</div>';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (config.showMute || config.showVolume) {
|
|
67
|
+
right += '<div class="wb-volume">';
|
|
68
|
+
right += `<button class="wb-btn wb-btn-sm wb-mute" aria-label="Volume" title="Volume">${ICONS.volHigh}</button>`;
|
|
69
|
+
if (config.showVolume) {
|
|
70
|
+
right += `<div class="wb-volume-popup">
|
|
71
|
+
<input type="range" class="wb-volume-slider" min="0" max="100" value="100" orient="vertical" aria-label="Volume">
|
|
72
|
+
</div>`;
|
|
73
|
+
}
|
|
74
|
+
right += '</div>';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config.showQueue) {
|
|
78
|
+
right += `<button class="wb-btn wb-btn-sm wb-queue-btn" aria-label="Queue" title="Queue">${ICONS.queue}</button>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
right += '</div>';
|
|
82
|
+
|
|
83
|
+
return `<div class="wb-inner">${left}${centre}${right}</div>`;
|
|
84
|
+
}
|
package/src/js/icons.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module icons
|
|
3
|
+
* @description SVG icons for WaveformBar (inline, no external deps)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ICONS = {
|
|
7
|
+
play: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>',
|
|
8
|
+
pause: '<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
|
|
9
|
+
prev: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>',
|
|
10
|
+
next: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>',
|
|
11
|
+
queue: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M3 13h2v-2H3v2zm0 4h2v-2H3v2zm0-8h2V7H3v2zm4 4h14v-2H7v2zm0 4h14v-2H7v2zM7 7v2h14V7H7z"/></svg>',
|
|
12
|
+
music: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" opacity="0.5"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55C7.79 13 6 14.79 6 17s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg>',
|
|
13
|
+
volHigh: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>',
|
|
14
|
+
volLow: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>',
|
|
15
|
+
volMute: '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>',
|
|
16
|
+
heart: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
|
|
17
|
+
heartFilled: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
|
|
18
|
+
cart: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/></svg>',
|
|
19
|
+
close: '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>',
|
|
20
|
+
speaker: '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>',
|
|
21
|
+
repeatOff: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>',
|
|
22
|
+
repeatAll: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/></svg>',
|
|
23
|
+
repeatOne: '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z"/><text x="12" y="15" text-anchor="middle" font-size="7" font-weight="bold" fill="currentColor">1</text></svg>'
|
|
24
|
+
};
|
package/src/js/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WaveformBar v1.0.0
|
|
3
|
+
* Persistent bottom audio player bar for WaveformPlayer
|
|
4
|
+
*
|
|
5
|
+
* @author ArrayPress
|
|
6
|
+
* @license MIT
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {WaveformBar} from './core.js';
|
|
10
|
+
|
|
11
|
+
// Create singleton instance
|
|
12
|
+
const instance = new WaveformBar();
|
|
13
|
+
|
|
14
|
+
// Browser global
|
|
15
|
+
if (typeof window !== 'undefined') {
|
|
16
|
+
window.WaveformBar = instance;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default instance;
|
|
20
|
+
export {WaveformBar};
|
package/src/js/queue.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module queue
|
|
3
|
+
* @description Queue panel rendering for WaveformBar
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {ICONS} from './icons.js';
|
|
7
|
+
import {escapeHtml} from './utils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Create the queue panel DOM element
|
|
11
|
+
* @returns {HTMLElement}
|
|
12
|
+
*/
|
|
13
|
+
export function createQueuePanel() {
|
|
14
|
+
const el = document.createElement('div');
|
|
15
|
+
el.className = 'wb-queue-panel';
|
|
16
|
+
el.innerHTML = `
|
|
17
|
+
<div class="wb-queue-header">
|
|
18
|
+
<div class="wb-queue-title">
|
|
19
|
+
${ICONS.queue}
|
|
20
|
+
Queue
|
|
21
|
+
<span class="wb-queue-count">0</span>
|
|
22
|
+
</div>
|
|
23
|
+
<button class="wb-btn wb-btn-sm wb-queue-clear" aria-label="Clear queue">Clear</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="wb-queue-body"></div>
|
|
26
|
+
`;
|
|
27
|
+
return el;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render queue panel contents
|
|
32
|
+
* @param {HTMLElement} bodyEl - Queue body element
|
|
33
|
+
* @param {HTMLElement} countEl - Queue count badge element
|
|
34
|
+
* @param {Array} queue - Queue array
|
|
35
|
+
* @param {number} currentIndex - Current playing index
|
|
36
|
+
* @param {Object} callbacks - { onSkipTo, onRemove }
|
|
37
|
+
*/
|
|
38
|
+
export function renderQueue(bodyEl, countEl, queue, currentIndex, callbacks) {
|
|
39
|
+
if (!bodyEl) return;
|
|
40
|
+
|
|
41
|
+
const upcoming = Math.max(0, queue.length - 1 - currentIndex);
|
|
42
|
+
if (countEl) countEl.textContent = upcoming;
|
|
43
|
+
|
|
44
|
+
if (queue.length === 0) {
|
|
45
|
+
bodyEl.innerHTML = `<div class="wb-queue-empty">${ICONS.queue}<p>Queue is empty</p></div>`;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let html = '';
|
|
50
|
+
|
|
51
|
+
// Now playing
|
|
52
|
+
if (currentIndex >= 0 && currentIndex < queue.length) {
|
|
53
|
+
const current = queue[currentIndex];
|
|
54
|
+
html += '<div class="wb-queue-label">Now Playing</div>';
|
|
55
|
+
html += `<div class="wb-queue-item wb-queue-current" data-qi="${currentIndex}">
|
|
56
|
+
<span class="wb-queue-num">${ICONS.speaker}</span>
|
|
57
|
+
<div class="wb-queue-info">
|
|
58
|
+
<div class="wb-queue-item-title">${escapeHtml(current.title)}</div>
|
|
59
|
+
<div class="wb-queue-item-artist">${escapeHtml(current.artist)}</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Next up
|
|
65
|
+
let hasNext = false;
|
|
66
|
+
for (let i = currentIndex + 1; i < queue.length; i++) {
|
|
67
|
+
if (!hasNext) {
|
|
68
|
+
html += '<div class="wb-queue-label">Next Up</div>';
|
|
69
|
+
hasNext = true;
|
|
70
|
+
}
|
|
71
|
+
const t = queue[i];
|
|
72
|
+
html += `<div class="wb-queue-item" data-qi="${i}">
|
|
73
|
+
<span class="wb-queue-num">${i - currentIndex}</span>
|
|
74
|
+
<div class="wb-queue-info">
|
|
75
|
+
<div class="wb-queue-item-title">${escapeHtml(t.title)}</div>
|
|
76
|
+
<div class="wb-queue-item-artist">${escapeHtml(t.artist)}</div>
|
|
77
|
+
</div>
|
|
78
|
+
<button class="wb-queue-remove" data-qi="${i}" aria-label="Remove">${ICONS.close}</button>
|
|
79
|
+
</div>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Previously played
|
|
83
|
+
if (currentIndex > 0) {
|
|
84
|
+
html += '<div class="wb-queue-label">Previously Played</div>';
|
|
85
|
+
for (let j = currentIndex - 1; j >= 0; j--) {
|
|
86
|
+
const t = queue[j];
|
|
87
|
+
html += `<div class="wb-queue-item wb-queue-played" data-qi="${j}">
|
|
88
|
+
<span class="wb-queue-num">${j + 1}</span>
|
|
89
|
+
<div class="wb-queue-info">
|
|
90
|
+
<div class="wb-queue-item-title">${escapeHtml(t.title)}</div>
|
|
91
|
+
<div class="wb-queue-item-artist">${escapeHtml(t.artist)}</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
bodyEl.innerHTML = html;
|
|
98
|
+
|
|
99
|
+
// Bind click events
|
|
100
|
+
bodyEl.querySelectorAll('.wb-queue-item[data-qi]').forEach(el => {
|
|
101
|
+
el.addEventListener('click', (e) => {
|
|
102
|
+
if (e.target.closest('.wb-queue-remove')) return;
|
|
103
|
+
if (callbacks.onSkipTo) callbacks.onSkipTo(parseInt(el.dataset.qi));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
bodyEl.querySelectorAll('.wb-queue-remove').forEach(btn => {
|
|
108
|
+
btn.addEventListener('click', (e) => {
|
|
109
|
+
e.stopPropagation();
|
|
110
|
+
if (callbacks.onRemove) callbacks.onRemove(parseInt(btn.dataset.qi));
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module storage
|
|
3
|
+
* @description Persistence helpers for WaveformBar
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Save queue state to sessionStorage
|
|
8
|
+
* @param {string} key - Storage key
|
|
9
|
+
* @param {Object} state - State to save
|
|
10
|
+
*/
|
|
11
|
+
export function saveQueueState(key, state) {
|
|
12
|
+
try {
|
|
13
|
+
sessionStorage.setItem(key, JSON.stringify(state));
|
|
14
|
+
} catch (e) {}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Restore queue state from sessionStorage
|
|
19
|
+
* @param {string} key - Storage key
|
|
20
|
+
* @returns {Object|null}
|
|
21
|
+
*/
|
|
22
|
+
export function restoreQueueState(key) {
|
|
23
|
+
try {
|
|
24
|
+
const raw = sessionStorage.getItem(key);
|
|
25
|
+
if (!raw) return null;
|
|
26
|
+
const d = JSON.parse(raw);
|
|
27
|
+
if (!d || !d.queue || !d.queue.length) return null;
|
|
28
|
+
return d;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
sessionStorage.removeItem(key);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Save volume to localStorage (persists across sessions)
|
|
37
|
+
* @param {string} key - Storage key
|
|
38
|
+
* @param {number} volume
|
|
39
|
+
* @param {boolean} muted
|
|
40
|
+
* @param {number} volumeBeforeMute
|
|
41
|
+
*/
|
|
42
|
+
export function saveVolume(key, volume, muted, volumeBeforeMute) {
|
|
43
|
+
try {
|
|
44
|
+
localStorage.setItem(key + '-vol', JSON.stringify({
|
|
45
|
+
v: volume, m: muted, b: volumeBeforeMute
|
|
46
|
+
}));
|
|
47
|
+
} catch (e) {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Restore volume from localStorage
|
|
52
|
+
* @param {string} key - Storage key
|
|
53
|
+
* @returns {Object|null} { volume, muted, volumeBeforeMute }
|
|
54
|
+
*/
|
|
55
|
+
export function restoreVolume(key) {
|
|
56
|
+
try {
|
|
57
|
+
const d = JSON.parse(localStorage.getItem(key + '-vol'));
|
|
58
|
+
if (!d) return null;
|
|
59
|
+
return {
|
|
60
|
+
volume: d.v != null ? d.v : 1,
|
|
61
|
+
muted: d.m || false,
|
|
62
|
+
volumeBeforeMute: d.b || 1
|
|
63
|
+
};
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Save favorites to localStorage
|
|
71
|
+
* @param {string} key - Storage key
|
|
72
|
+
* @param {Set} favorites
|
|
73
|
+
*/
|
|
74
|
+
export function saveFavorites(key, favorites) {
|
|
75
|
+
try {
|
|
76
|
+
localStorage.setItem(key + '-favs', JSON.stringify([...favorites]));
|
|
77
|
+
} catch (e) {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Restore favorites from localStorage
|
|
82
|
+
* @param {string} key - Storage key
|
|
83
|
+
* @returns {Set}
|
|
84
|
+
*/
|
|
85
|
+
export function restoreFavorites(key) {
|
|
86
|
+
try {
|
|
87
|
+
const d = JSON.parse(localStorage.getItem(key + '-favs'));
|
|
88
|
+
return Array.isArray(d) ? new Set(d) : new Set();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return new Set();
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/js/utils.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils
|
|
3
|
+
* @description Utility functions for WaveformBar
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Extract a display title from a URL
|
|
8
|
+
* @param {string} url
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function extractTitle(url) {
|
|
12
|
+
if (!url) return 'Untitled';
|
|
13
|
+
return url.split('/').pop().split('.')[0]
|
|
14
|
+
.replace(/[-_]/g, ' ')
|
|
15
|
+
.replace(/\b\w/g, l => l.toUpperCase());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Escape HTML to prevent XSS
|
|
20
|
+
* @param {string} str
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function escapeHtml(str) {
|
|
24
|
+
if (!str) return '';
|
|
25
|
+
const d = document.createElement('div');
|
|
26
|
+
d.textContent = str;
|
|
27
|
+
return d.innerHTML;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Format seconds to M:SS
|
|
32
|
+
* @param {number} seconds
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function formatTime(seconds) {
|
|
36
|
+
if (!seconds || isNaN(seconds)) return '0:00';
|
|
37
|
+
const m = Math.floor(seconds / 60);
|
|
38
|
+
const s = Math.floor(seconds % 60);
|
|
39
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse track metadata from a trigger element
|
|
44
|
+
* @param {HTMLElement} el
|
|
45
|
+
* @returns {Object|null}
|
|
46
|
+
*/
|
|
47
|
+
export function parseTrackFromElement(el) {
|
|
48
|
+
const url = el.dataset.wbUrl || el.dataset.url;
|
|
49
|
+
if (!url) return null;
|
|
50
|
+
|
|
51
|
+
let meta = {};
|
|
52
|
+
try { meta = JSON.parse(el.dataset.wbMeta || el.dataset.meta || '{}'); } catch (e) {}
|
|
53
|
+
|
|
54
|
+
let markers = null;
|
|
55
|
+
try { markers = JSON.parse(el.dataset.wbMarkers || el.dataset.markers || 'null'); } catch (e) {}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
url,
|
|
59
|
+
id: el.dataset.wbId || el.dataset.id || url,
|
|
60
|
+
title: el.dataset.wbTitle || el.dataset.title || extractTitle(url),
|
|
61
|
+
artist: el.dataset.wbArtist || el.dataset.artist || '',
|
|
62
|
+
artwork: el.dataset.wbArtwork || el.dataset.artwork || '',
|
|
63
|
+
album: el.dataset.wbAlbum || el.dataset.album || '',
|
|
64
|
+
link: el.dataset.wbLink || el.dataset.link || '',
|
|
65
|
+
duration: el.dataset.wbDuration || el.dataset.duration || '',
|
|
66
|
+
bpm: el.dataset.wbBpm || el.dataset.bpm || '',
|
|
67
|
+
key: el.dataset.wbKey || el.dataset.key || '',
|
|
68
|
+
waveform: el.dataset.wbWaveform || el.dataset.waveform || '',
|
|
69
|
+
markers,
|
|
70
|
+
favorited: el.dataset.wbFavorited === 'true',
|
|
71
|
+
inCart: el.dataset.wbInCart === 'true',
|
|
72
|
+
meta
|
|
73
|
+
};
|
|
74
|
+
}
|