@arraypress/waveform-player 1.7.2 → 1.8.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/README.md +176 -377
- package/dist/waveform-player.cjs +2207 -0
- package/dist/waveform-player.cjs.map +7 -0
- package/dist/waveform-player.css +1 -1
- package/dist/waveform-player.esm.js +8 -8
- package/dist/waveform-player.esm.js.map +7 -0
- package/dist/waveform-player.js +552 -275
- package/dist/waveform-player.min.js +8 -8
- package/dist/waveform-player.min.js.map +7 -0
- package/index.d.ts +344 -0
- package/package.json +18 -8
- package/src/css/waveform-player.css +21 -3
- package/src/js/audio.js +61 -25
- package/src/js/bpm.js +26 -5
- package/src/js/core.js +409 -185
- package/src/js/drawing.js +208 -44
- package/src/js/index.js +56 -11
- package/src/js/themes.js +88 -47
- package/src/js/utils.js +231 -65
package/src/js/themes.js
CHANGED
|
@@ -3,57 +3,61 @@
|
|
|
3
3
|
* @description Color presets and default options for WaveformPlayer
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import {perceivedBrightness} from './utils.js';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Does `<html>` or `<body>` explicitly signal the given colour scheme via a
|
|
10
|
+
* known class name (`dark`, `dark-mode`, `theme-dark`) or theme attribute
|
|
11
|
+
* (`data-theme`, and `data-color-scheme` on the root)?
|
|
12
|
+
* @param {'dark'|'light'} scheme - Scheme to look for.
|
|
13
|
+
* @returns {boolean} True if the page explicitly hints at `scheme`.
|
|
14
|
+
* @private
|
|
10
15
|
*/
|
|
11
|
-
|
|
16
|
+
function hasThemeHint(scheme) {
|
|
12
17
|
const root = document.documentElement;
|
|
13
18
|
const body = document.body;
|
|
19
|
+
return (
|
|
20
|
+
root.classList.contains(scheme) ||
|
|
21
|
+
root.classList.contains(`${scheme}-mode`) ||
|
|
22
|
+
root.classList.contains(`theme-${scheme}`) ||
|
|
23
|
+
root.getAttribute('data-theme') === scheme ||
|
|
24
|
+
root.getAttribute('data-color-scheme') === scheme ||
|
|
25
|
+
body.classList.contains(scheme) ||
|
|
26
|
+
body.classList.contains(`${scheme}-mode`) ||
|
|
27
|
+
body.getAttribute('data-theme') === scheme
|
|
28
|
+
);
|
|
29
|
+
}
|
|
14
30
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
body.classList.contains('light') ||
|
|
35
|
-
body.classList.contains('light-mode') ||
|
|
36
|
-
body.getAttribute('data-theme') === 'light') {
|
|
37
|
-
return 'light';
|
|
38
|
-
}
|
|
31
|
+
/**
|
|
32
|
+
* Detect the appropriate color scheme for the player from the surrounding page.
|
|
33
|
+
*
|
|
34
|
+
* Resolution order, first match wins:
|
|
35
|
+
* 1. Explicit theme hints on `<html>`/`<body>` — class names
|
|
36
|
+
* (`dark`, `dark-mode`, `theme-dark`, light equivalents) and data
|
|
37
|
+
* attributes (`data-theme`, `data-color-scheme`).
|
|
38
|
+
* 2. The page's computed `<body>` background colour, classified via
|
|
39
|
+
* {@link perceivedBrightness} (>128 = light, <128 = dark; exactly 128
|
|
40
|
+
* or unparseable is treated as ambiguous and falls through).
|
|
41
|
+
* 3. The OS/browser `prefers-color-scheme` media query.
|
|
42
|
+
* 4. Default fallback of `'dark'` (most audio players are dark).
|
|
43
|
+
*
|
|
44
|
+
* @returns {string} The detected scheme, either `'dark'` or `'light'`.
|
|
45
|
+
*/
|
|
46
|
+
export function detectColorScheme() {
|
|
47
|
+
// 1. Explicit theme class names / data attributes win.
|
|
48
|
+
if (hasThemeHint('dark')) return 'dark';
|
|
49
|
+
if (hasThemeHint('light')) return 'light';
|
|
39
50
|
|
|
40
51
|
// 2. Try to detect website's theme from background color
|
|
41
52
|
try {
|
|
42
53
|
const bodyBg = getComputedStyle(document.body).backgroundColor;
|
|
54
|
+
const brightness = perceivedBrightness(bodyBg);
|
|
43
55
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
|
50
|
-
|
|
51
|
-
// Clear determination: bright background = light theme
|
|
52
|
-
if (brightness > 128) {
|
|
53
|
-
return 'light';
|
|
54
|
-
} else if (brightness < 128) {
|
|
55
|
-
return 'dark';
|
|
56
|
-
}
|
|
56
|
+
// Clear determination: bright background = light theme. Exactly 128
|
|
57
|
+
// (or unparseable) is ambiguous — fall through to the next method.
|
|
58
|
+
if (brightness !== null) {
|
|
59
|
+
if (brightness > 128) return 'light';
|
|
60
|
+
if (brightness < 128) return 'dark';
|
|
57
61
|
}
|
|
58
62
|
} catch (e) {
|
|
59
63
|
// If background detection fails, continue to next method
|
|
@@ -74,7 +78,17 @@ export function detectColorScheme() {
|
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
77
|
-
*
|
|
81
|
+
* Built-in colour presets keyed by scheme name.
|
|
82
|
+
*
|
|
83
|
+
* Each preset is a flat map of the player's themeable colour tokens
|
|
84
|
+
* (waveform, progress, button, text, background, border). They are deliberately
|
|
85
|
+
* simple translucent black/white values so they sit on any host background, and
|
|
86
|
+
* any individual token can be overridden per-instance via the matching
|
|
87
|
+
* `*Color` option in {@link DEFAULT_OPTIONS}.
|
|
88
|
+
*
|
|
89
|
+
* @type {Object<string, Object<string, string>>}
|
|
90
|
+
* @property {Object<string, string>} dark Light-on-dark token set.
|
|
91
|
+
* @property {Object<string, string>} light Dark-on-light token set.
|
|
78
92
|
*/
|
|
79
93
|
export const COLOR_PRESETS = {
|
|
80
94
|
dark: {
|
|
@@ -100,9 +114,16 @@ export const COLOR_PRESETS = {
|
|
|
100
114
|
};
|
|
101
115
|
|
|
102
116
|
/**
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
117
|
+
* Resolve a colour preset by name, falling back to auto-detection.
|
|
118
|
+
*
|
|
119
|
+
* When `presetName` names a known preset it is returned as-is; otherwise
|
|
120
|
+
* (null, undefined, or an unrecognised name) the scheme is auto-detected via
|
|
121
|
+
* {@link detectColorScheme} and the corresponding preset is returned.
|
|
122
|
+
*
|
|
123
|
+
* @param {string|null} presetName - Preset name (`'dark'` or `'light'`), or
|
|
124
|
+
* null/invalid to trigger auto-detection.
|
|
125
|
+
* @returns {Object<string, string>} The matching colour token map from
|
|
126
|
+
* {@link COLOR_PRESETS}.
|
|
106
127
|
*/
|
|
107
128
|
export function getColorPreset(presetName) {
|
|
108
129
|
// If explicitly set to a valid preset, use it
|
|
@@ -116,7 +137,16 @@ export function getColorPreset(presetName) {
|
|
|
116
137
|
}
|
|
117
138
|
|
|
118
139
|
/**
|
|
119
|
-
* Default
|
|
140
|
+
* Default option set for a {@link WaveformPlayer} instance.
|
|
141
|
+
*
|
|
142
|
+
* User-supplied options are merged over this object, so every supported option
|
|
143
|
+
* is enumerated here with its baseline value. `null` colour tokens mean "inherit
|
|
144
|
+
* from the resolved {@link COLOR_PRESETS} preset"; `null` content/callback
|
|
145
|
+
* fields mean "unset". See the grouped inline comments for per-field notes,
|
|
146
|
+
* notably the `audioMode` self/external distinction and the `accessibleSeek`
|
|
147
|
+
* keyboard slider.
|
|
148
|
+
*
|
|
149
|
+
* @type {Object}
|
|
120
150
|
*/
|
|
121
151
|
export const DEFAULT_OPTIONS = {
|
|
122
152
|
// Core settings
|
|
@@ -144,6 +174,8 @@ export const DEFAULT_OPTIONS = {
|
|
|
144
174
|
waveformStyle: 'mirror',
|
|
145
175
|
barWidth: 2,
|
|
146
176
|
barSpacing: 0,
|
|
177
|
+
// Rounded bar caps (px). 0 = square (default). Applies to bars/mirror.
|
|
178
|
+
barRadius: 0,
|
|
147
179
|
|
|
148
180
|
// Color preset: null = auto-detect, 'dark' = force dark, 'light' = force light
|
|
149
181
|
colorPreset: null,
|
|
@@ -186,6 +218,9 @@ export const DEFAULT_OPTIONS = {
|
|
|
186
218
|
artwork: null,
|
|
187
219
|
album: '',
|
|
188
220
|
|
|
221
|
+
// Message shown in the error state when audio fails to load.
|
|
222
|
+
errorText: 'Unable to load audio',
|
|
223
|
+
|
|
189
224
|
// Icons (SVG)
|
|
190
225
|
playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
|
|
191
226
|
pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
|
|
@@ -200,7 +235,13 @@ export const DEFAULT_OPTIONS = {
|
|
|
200
235
|
};
|
|
201
236
|
|
|
202
237
|
/**
|
|
203
|
-
*
|
|
238
|
+
* Per-waveform-style geometry defaults.
|
|
239
|
+
*
|
|
240
|
+
* Maps each supported `waveformStyle` to its natural `barWidth`/`barSpacing`
|
|
241
|
+
* (in px), used to seed bar geometry when the caller has not explicitly set
|
|
242
|
+
* those options so each style renders at sensible proportions.
|
|
243
|
+
*
|
|
244
|
+
* @type {Object<string, {barWidth: number, barSpacing: number}>}
|
|
204
245
|
*/
|
|
205
246
|
export const STYLE_DEFAULTS = {
|
|
206
247
|
bars: {barWidth: 3, barSpacing: 1},
|
package/src/js/utils.js
CHANGED
|
@@ -4,33 +4,146 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @
|
|
7
|
+
* Escape a string for safe interpolation into HTML, preventing injection when
|
|
8
|
+
* building markup with template strings. `null`/`undefined` become `''`.
|
|
9
|
+
* @param {*} str - Value to escape.
|
|
10
|
+
* @returns {string} HTML-escaped string.
|
|
11
|
+
*/
|
|
12
|
+
export function escapeHtml(str) {
|
|
13
|
+
return String(str == null ? '' : str)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether a URL is safe to navigate to (assign to `location.href`): allows only
|
|
23
|
+
* `http`/`https` and relative URLs, rejecting `javascript:`, `data:`, `blob:`,
|
|
24
|
+
* `vbscript:` and other script-bearing schemes.
|
|
25
|
+
* @param {string} url - Candidate URL.
|
|
26
|
+
* @returns {boolean} True if the URL uses a safe scheme.
|
|
27
|
+
*/
|
|
28
|
+
export function isSafeHref(url) {
|
|
29
|
+
if (typeof url !== 'string' || url === '') return false;
|
|
30
|
+
try {
|
|
31
|
+
// Resolve relative URLs against a dummy http base; only the scheme matters.
|
|
32
|
+
const u = new URL(url, 'http://localhost/');
|
|
33
|
+
return u.protocol === 'http:' || u.protocol === 'https:';
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Clamp a number to an inclusive range.
|
|
41
|
+
* @param {number} value - Value to constrain.
|
|
42
|
+
* @param {number} [min=0] - Lower bound.
|
|
43
|
+
* @param {number} [max=1] - Upper bound.
|
|
44
|
+
* @returns {number} `value` constrained to `[min, max]`.
|
|
45
|
+
*/
|
|
46
|
+
export function clamp(value, min = 0, max = 1) {
|
|
47
|
+
return Math.max(min, Math.min(value, max));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read a boolean `data-*` flag. Returns `undefined` when the attribute is
|
|
52
|
+
* absent (preserving the sparse-options contract) and otherwise compares the
|
|
53
|
+
* raw value against the literal string `'true'`.
|
|
54
|
+
* @param {string|undefined} value - Raw `dataset` value.
|
|
55
|
+
* @returns {boolean|undefined} `true`/`false` when present, else `undefined`.
|
|
56
|
+
*/
|
|
57
|
+
export function parseBoolAttr(value) {
|
|
58
|
+
return value === undefined ? undefined : value === 'true';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A colour data-attribute may be a CSS colour string OR a JSON array of
|
|
63
|
+
* gradient stops (e.g. '["#fafafa","#71717a"]'). Parse the array form;
|
|
64
|
+
* otherwise pass the string straight through.
|
|
65
|
+
* @param {string} value
|
|
66
|
+
* @returns {string|string[]}
|
|
67
|
+
*/
|
|
68
|
+
function parseColorValue(value) {
|
|
69
|
+
if (typeof value === 'string' && value.trim().startsWith('[')) {
|
|
70
|
+
try { return JSON.parse(value); } catch (e) { /* fall through to string */ }
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read every recognised `data-*` attribute off a host element and translate it
|
|
77
|
+
* into a plain options object suitable for `mergeOptions`.
|
|
78
|
+
*
|
|
79
|
+
* Only attributes that are actually present are copied, so the returned object
|
|
80
|
+
* is sparse and never overrides defaults with `undefined`. Numeric attributes
|
|
81
|
+
* are coerced with `parseInt`/`parseFloat`, boolean flags are compared against
|
|
82
|
+
* the literal string `'true'`, and JSON-valued attributes (`markers`,
|
|
83
|
+
* `playbackRates`) are parsed defensively — a parse failure is warned about and
|
|
84
|
+
* the attribute is skipped rather than thrown.
|
|
85
|
+
*
|
|
86
|
+
* Several attributes are shorthand aliases of a canonical long form: `data-src`
|
|
87
|
+
* → `url`, `data-style` → `waveformStyle`. When both are present the canonical
|
|
88
|
+
* long form is applied last and therefore wins. `data-color` and `data-theme`
|
|
89
|
+
* are retained as legacy aliases for `waveformColor` and `colorPreset`.
|
|
90
|
+
* Colour attributes that accept gradients (`waveformColor`, `progressColor`)
|
|
91
|
+
* are passed through {@link parseColorValue} so a JSON stop array is expanded.
|
|
92
|
+
*
|
|
93
|
+
* @param {HTMLElement} element - Host element whose `dataset` is inspected.
|
|
94
|
+
* @returns {Object} Sparse options object containing only the attributes found.
|
|
10
95
|
*/
|
|
11
96
|
export function parseDataAttributes(element) {
|
|
12
97
|
const options = {};
|
|
13
98
|
|
|
14
|
-
//
|
|
99
|
+
// Set a boolean option only when its `data-*` attribute is present, so the
|
|
100
|
+
// returned object stays sparse and never overrides a default with a value
|
|
101
|
+
// the author didn't set. (`dataKey` differs from `optKey` only for showBPM.)
|
|
102
|
+
const setBool = (optKey, dataKey = optKey) => {
|
|
103
|
+
const v = parseBoolAttr(element.dataset[dataKey]);
|
|
104
|
+
if (v !== undefined) options[optKey] = v;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Read a present (non-empty) numeric attribute as an int (or float).
|
|
108
|
+
const setNum = (optKey, dataKey = optKey, float = false) => {
|
|
109
|
+
const raw = element.dataset[dataKey];
|
|
110
|
+
if (raw) options[optKey] = float ? parseFloat(raw) : parseInt(raw, 10);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Parse a JSON-valued attribute defensively — warn and skip on bad JSON.
|
|
114
|
+
const setJson = (optKey, dataKey = optKey) => {
|
|
115
|
+
const raw = element.dataset[dataKey];
|
|
116
|
+
if (!raw) return;
|
|
117
|
+
try { options[optKey] = JSON.parse(raw); }
|
|
118
|
+
catch (e) { console.warn(`[WaveformPlayer] Invalid ${dataKey} JSON:`, e); }
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Core attributes. `data-src` is a shorthand alias for `data-url`;
|
|
122
|
+
// the canonical long form wins if both are set.
|
|
123
|
+
if (element.dataset.src) options.url = element.dataset.src;
|
|
15
124
|
if (element.dataset.url) options.url = element.dataset.url;
|
|
16
|
-
|
|
17
|
-
|
|
125
|
+
setNum('height');
|
|
126
|
+
setNum('samples');
|
|
18
127
|
if (element.dataset.preload) {
|
|
19
128
|
options.preload = element.dataset.preload;
|
|
20
129
|
}
|
|
130
|
+
if (element.dataset.audioMode) options.audioMode = element.dataset.audioMode;
|
|
21
131
|
|
|
22
|
-
// Waveform style attributes
|
|
132
|
+
// Waveform style attributes. `data-style` is a shorthand alias for
|
|
133
|
+
// `data-waveform-style`; the canonical long form wins if both are set.
|
|
134
|
+
if (element.dataset.style) options.waveformStyle = element.dataset.style;
|
|
23
135
|
if (element.dataset.waveformStyle) options.waveformStyle = element.dataset.waveformStyle;
|
|
24
|
-
|
|
25
|
-
|
|
136
|
+
setNum('barWidth');
|
|
137
|
+
setNum('barSpacing');
|
|
138
|
+
setNum('barRadius');
|
|
26
139
|
if (element.dataset.buttonAlign) options.buttonAlign = element.dataset.buttonAlign;
|
|
27
140
|
|
|
28
141
|
// Color preset
|
|
29
142
|
if (element.dataset.colorPreset) options.colorPreset = element.dataset.colorPreset;
|
|
30
143
|
|
|
31
144
|
// Individual color customization
|
|
32
|
-
if (element.dataset.waveformColor) options.waveformColor = element.dataset.waveformColor;
|
|
33
|
-
if (element.dataset.progressColor) options.progressColor = element.dataset.progressColor;
|
|
145
|
+
if (element.dataset.waveformColor) options.waveformColor = parseColorValue(element.dataset.waveformColor);
|
|
146
|
+
if (element.dataset.progressColor) options.progressColor = parseColorValue(element.dataset.progressColor);
|
|
34
147
|
if (element.dataset.buttonColor) options.buttonColor = element.dataset.buttonColor;
|
|
35
148
|
if (element.dataset.buttonHoverColor) options.buttonHoverColor = element.dataset.buttonHoverColor;
|
|
36
149
|
if (element.dataset.textColor) options.textColor = element.dataset.textColor;
|
|
@@ -43,14 +156,14 @@ export function parseDataAttributes(element) {
|
|
|
43
156
|
if (element.dataset.theme) options.colorPreset = element.dataset.theme;
|
|
44
157
|
|
|
45
158
|
// Feature flags
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
159
|
+
setBool('autoplay');
|
|
160
|
+
setBool('showControls');
|
|
161
|
+
setBool('showInfo');
|
|
162
|
+
setBool('showTime');
|
|
163
|
+
setBool('showHoverTime');
|
|
164
|
+
setBool('showBPM', 'showBpm');
|
|
165
|
+
setBool('singlePlay');
|
|
166
|
+
setBool('playOnSeek');
|
|
54
167
|
|
|
55
168
|
// Content and metadata
|
|
56
169
|
if (element.dataset.title) options.title = element.dataset.title;
|
|
@@ -62,65 +175,90 @@ export function parseDataAttributes(element) {
|
|
|
62
175
|
if (element.dataset.waveform) options.waveform = element.dataset.waveform;
|
|
63
176
|
|
|
64
177
|
// Markers
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
options.markers = JSON.parse(element.dataset.markers);
|
|
68
|
-
} catch (e) {
|
|
69
|
-
console.warn('Invalid markers JSON:', e);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
178
|
+
setJson('markers');
|
|
72
179
|
|
|
73
180
|
// Playback controls
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (element.dataset.showPlaybackSpeed !== undefined) {
|
|
78
|
-
options.showPlaybackSpeed = element.dataset.showPlaybackSpeed === 'true';
|
|
79
|
-
}
|
|
80
|
-
if (element.dataset.playbackRates) {
|
|
81
|
-
try {
|
|
82
|
-
options.playbackRates = JSON.parse(element.dataset.playbackRates);
|
|
83
|
-
} catch (e) {
|
|
84
|
-
console.warn('Invalid playbackRates JSON:', e);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
181
|
+
setNum('playbackRate', 'playbackRate', true);
|
|
182
|
+
setBool('showPlaybackSpeed');
|
|
183
|
+
setJson('playbackRates');
|
|
87
184
|
|
|
88
185
|
// Media Session API
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
186
|
+
setBool('enableMediaSession');
|
|
187
|
+
|
|
188
|
+
// Markers visibility
|
|
189
|
+
setBool('showMarkers');
|
|
190
|
+
|
|
191
|
+
// Accessibility
|
|
192
|
+
setBool('accessibleSeek');
|
|
193
|
+
if (element.dataset.seekLabel) options.seekLabel = element.dataset.seekLabel;
|
|
194
|
+
if (element.dataset.errorText) options.errorText = element.dataset.errorText;
|
|
195
|
+
|
|
196
|
+
// Custom icons (raw SVG markup)
|
|
197
|
+
if (element.dataset.playIcon) options.playIcon = element.dataset.playIcon;
|
|
198
|
+
if (element.dataset.pauseIcon) options.pauseIcon = element.dataset.pauseIcon;
|
|
92
199
|
|
|
93
200
|
return options;
|
|
94
201
|
}
|
|
95
202
|
|
|
96
203
|
/**
|
|
97
|
-
* Format
|
|
98
|
-
*
|
|
99
|
-
*
|
|
204
|
+
* Format a duration as a clock string.
|
|
205
|
+
*
|
|
206
|
+
* Renders `M:SS` for durations under an hour and `H:MM:SS` for longer ones,
|
|
207
|
+
* zero-padding the minutes and seconds. Falsy, `NaN`, or negative inputs are
|
|
208
|
+
* treated as zero and return `'0:00'`.
|
|
209
|
+
* @param {number} seconds - Time in seconds.
|
|
210
|
+
* @returns {string} Formatted time, e.g. `'3:07'` or `'1:02:09'`.
|
|
100
211
|
*/
|
|
101
212
|
export function formatTime(seconds) {
|
|
102
|
-
if (!seconds || isNaN(seconds)) return '0:00';
|
|
213
|
+
if (!seconds || isNaN(seconds) || seconds < 0) return '0:00';
|
|
103
214
|
|
|
104
|
-
const
|
|
215
|
+
const hrs = Math.floor(seconds / 3600);
|
|
216
|
+
const mins = Math.floor((seconds % 3600) / 60);
|
|
105
217
|
const secs = Math.floor(seconds % 60);
|
|
106
218
|
|
|
219
|
+
if (hrs > 0) {
|
|
220
|
+
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
107
223
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
108
224
|
}
|
|
109
225
|
|
|
110
226
|
/**
|
|
111
|
-
*
|
|
227
|
+
* Monotonic per-process counter appended to every generated id to guarantee
|
|
228
|
+
* uniqueness even when two ids hash from the same URL.
|
|
229
|
+
* @type {number}
|
|
230
|
+
* @private
|
|
231
|
+
*/
|
|
232
|
+
let idCounter = 0;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Generate a unique, DOM-safe ID from a URL.
|
|
236
|
+
*
|
|
237
|
+
* Uses a DJB2 hash of the FULL url (not a 10-char prefix) plus a process
|
|
238
|
+
* counter, so same-host tracks don't collide in the instances map and
|
|
239
|
+
* non-Latin1 / Unicode URLs don't throw (the old btoa() approach did both).
|
|
112
240
|
* @param {string} url - Audio URL
|
|
113
|
-
* @returns {string}
|
|
241
|
+
* @returns {string} Unique element-id-safe string
|
|
114
242
|
*/
|
|
115
243
|
export function generateId(url) {
|
|
116
|
-
const str = url ||
|
|
117
|
-
|
|
244
|
+
const str = url || 'audio';
|
|
245
|
+
let hash = 5381;
|
|
246
|
+
for (let i = 0; i < str.length; i++) {
|
|
247
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
248
|
+
}
|
|
249
|
+
return `wp_${(hash >>> 0).toString(36)}_${(idCounter++).toString(36)}`;
|
|
118
250
|
}
|
|
119
251
|
|
|
120
252
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
253
|
+
* Derive a human-readable title from an audio URL's filename.
|
|
254
|
+
*
|
|
255
|
+
* Takes the last path segment, drops the extension, replaces `-`/`_`
|
|
256
|
+
* separators with spaces, and title-cases the first letter of each word.
|
|
257
|
+
* Returns `'Audio'` for an empty or missing URL.
|
|
258
|
+
* @param {string} url - Audio URL.
|
|
259
|
+
* @returns {string} Extracted, prettified title.
|
|
260
|
+
* @example
|
|
261
|
+
* extractTitleFromUrl('https://cdn.example.com/my-cool_track.mp3'); // 'My Cool Track'
|
|
124
262
|
*/
|
|
125
263
|
export function extractTitleFromUrl(url) {
|
|
126
264
|
if (!url) return 'Audio';
|
|
@@ -136,9 +274,26 @@ export function extractTitleFromUrl(url) {
|
|
|
136
274
|
}
|
|
137
275
|
|
|
138
276
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* @
|
|
277
|
+
* Perceived brightness (0–255) of a CSS colour, via the luminance formula.
|
|
278
|
+
* Pulls the numeric channels out of an `rgb()`/`rgba()` string.
|
|
279
|
+
* @param {string} color - CSS colour string, e.g. `"rgb(34, 34, 34)"`.
|
|
280
|
+
* @returns {number|null} Brightness 0–255, or `null` if it can't be parsed.
|
|
281
|
+
*/
|
|
282
|
+
export function perceivedBrightness(color) {
|
|
283
|
+
const rgb = typeof color === 'string' ? color.match(/\d+/g) : null;
|
|
284
|
+
if (!rgb || rgb.length < 3) return null;
|
|
285
|
+
const [r, g, b] = rgb.map(Number);
|
|
286
|
+
return (r * 299 + g * 587 + b * 114) / 1000;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Shallow-merge option objects into a new object, last source winning.
|
|
291
|
+
*
|
|
292
|
+
* Keys whose value is `null` or `undefined` are skipped, so a later source can
|
|
293
|
+
* leave an earlier value untouched by passing a nullish entry rather than
|
|
294
|
+
* clobbering it. The inputs are never mutated.
|
|
295
|
+
* @param {...Object} sources - Option objects merged left-to-right.
|
|
296
|
+
* @returns {Object} A fresh object containing the merged, defined keys.
|
|
142
297
|
*/
|
|
143
298
|
export function mergeOptions(...sources) {
|
|
144
299
|
const result = {};
|
|
@@ -155,10 +310,14 @@ export function mergeOptions(...sources) {
|
|
|
155
310
|
}
|
|
156
311
|
|
|
157
312
|
/**
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
313
|
+
* Wrap a function so it only runs once calls stop arriving for `wait` ms.
|
|
314
|
+
*
|
|
315
|
+
* Each invocation resets the pending timer, so rapid bursts collapse into a
|
|
316
|
+
* single trailing-edge call that receives the most recent arguments. The
|
|
317
|
+
* wrapper itself returns nothing.
|
|
318
|
+
* @param {Function} func - Function to debounce.
|
|
319
|
+
* @param {number} wait - Idle period in milliseconds before `func` fires.
|
|
320
|
+
* @returns {Function} Debounced wrapper forwarding its arguments to `func`.
|
|
162
321
|
*/
|
|
163
322
|
export function debounce(func, wait) {
|
|
164
323
|
let timeout;
|
|
@@ -175,10 +334,17 @@ export function debounce(func, wait) {
|
|
|
175
334
|
}
|
|
176
335
|
|
|
177
336
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
337
|
+
* Resize a waveform amplitude array to a target number of bars.
|
|
338
|
+
*
|
|
339
|
+
* Returns the original array unchanged when lengths already match, and an empty
|
|
340
|
+
* array when either side is empty. When upsampling (target larger than source)
|
|
341
|
+
* it linearly interpolates between neighbouring samples for a smooth result.
|
|
342
|
+
* When downsampling it splits the source into evenly sized buckets and keeps
|
|
343
|
+
* the peak (maximum) of each so transients survive the reduction; an empty
|
|
344
|
+
* bucket falls back to its nearest-neighbour sample.
|
|
345
|
+
* @param {number[]} data - Original amplitude samples.
|
|
346
|
+
* @param {number} targetLength - Desired number of output bars.
|
|
347
|
+
* @returns {number[]} Resampled amplitude array of length `targetLength`.
|
|
182
348
|
*/
|
|
183
349
|
export function resampleData(data, targetLength) {
|
|
184
350
|
if (data.length === targetLength) return data;
|