@arraypress/waveform-player 1.7.1 → 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/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
- * Detect appropriate color scheme
8
- * Priority: 1) Explicit classes, 2) Website background, 3) System preference, 4) Default
9
- * @returns {string} 'dark' or 'light'
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
- export function detectColorScheme() {
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
- // 1. Check for explicit theme class names and data attributes FIRST
16
- // Check for dark theme indicators
17
- if (root.classList.contains('dark') ||
18
- root.classList.contains('dark-mode') ||
19
- root.classList.contains('theme-dark') ||
20
- root.getAttribute('data-theme') === 'dark' ||
21
- root.getAttribute('data-color-scheme') === 'dark' ||
22
- body.classList.contains('dark') ||
23
- body.classList.contains('dark-mode') ||
24
- body.getAttribute('data-theme') === 'dark') {
25
- return 'dark';
26
- }
27
-
28
- // Check for light theme indicators
29
- if (root.classList.contains('light') ||
30
- root.classList.contains('light-mode') ||
31
- root.classList.contains('theme-light') ||
32
- root.getAttribute('data-theme') === 'light' ||
33
- root.getAttribute('data-color-scheme') === 'light' ||
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
- // Parse RGB values
45
- const rgb = bodyBg.match(/\d+/g);
46
- if (rgb && rgb.length >= 3) {
47
- const [r, g, b] = rgb.map(Number);
48
- // Calculate perceived brightness using luminance formula (0-255)
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
- * Color presets - simple dark/light defaults that can be overridden
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
- * Get color preset by name, with auto-detection fallback
104
- * @param {string|null} presetName - Preset name ('dark', 'light') or null for auto-detect
105
- * @returns {Object} Color preset object
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 player options
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,
@@ -173,12 +205,22 @@ export const DEFAULT_OPTIONS = {
173
205
  markers: [],
174
206
  showMarkers: true,
175
207
 
208
+ // Accessibility — expose the waveform as a keyboard-operable slider
209
+ // (role="slider" + ARIA value attributes + arrow/page/home/end seeking).
210
+ // seekLabel sets the slider's accessible name; when null it falls back
211
+ // to the track title, then 'Seek'.
212
+ accessibleSeek: true,
213
+ seekLabel: null,
214
+
176
215
  // Content
177
216
  title: null,
178
217
  subtitle: null,
179
218
  artwork: null,
180
219
  album: '',
181
220
 
221
+ // Message shown in the error state when audio fails to load.
222
+ errorText: 'Unable to load audio',
223
+
182
224
  // Icons (SVG)
183
225
  playIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>',
184
226
  pauseIcon: '<svg viewBox="0 0 24 24" width="16" height="16"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>',
@@ -193,7 +235,13 @@ export const DEFAULT_OPTIONS = {
193
235
  };
194
236
 
195
237
  /**
196
- * Style defaults
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}>}
197
245
  */
198
246
  export const STYLE_DEFAULTS = {
199
247
  bars: {barWidth: 3, barSpacing: 1},
package/src/js/utils.js CHANGED
@@ -4,33 +4,146 @@
4
4
  */
5
5
 
6
6
  /**
7
- * Parse data attributes from element
8
- * @param {HTMLElement} element - Element with data attributes
9
- * @returns {Object} Parsed options
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, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&#39;');
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
- // Core attributes
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
- if (element.dataset.height) options.height = parseInt(element.dataset.height);
17
- if (element.dataset.samples) options.samples = parseInt(element.dataset.samples);
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
- if (element.dataset.barWidth) options.barWidth = parseInt(element.dataset.barWidth);
25
- if (element.dataset.barSpacing) options.barSpacing = parseInt(element.dataset.barSpacing);
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
- if (element.dataset.autoplay) options.autoplay = element.dataset.autoplay === 'true';
47
- if (element.dataset.showControls !== undefined) options.showControls = element.dataset.showControls === 'true';
48
- if (element.dataset.showInfo !== undefined) options.showInfo = element.dataset.showInfo === 'true';
49
- if (element.dataset.showTime) options.showTime = element.dataset.showTime === 'true';
50
- if (element.dataset.showHoverTime) options.showHoverTime = element.dataset.showHoverTime === 'true';
51
- if (element.dataset.showBpm) options.showBPM = element.dataset.showBpm === 'true';
52
- if (element.dataset.singlePlay) options.singlePlay = element.dataset.singlePlay === 'true';
53
- if (element.dataset.playOnSeek) options.playOnSeek = element.dataset.playOnSeek === 'true';
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
- if (element.dataset.markers) {
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
- if (element.dataset.playbackRate) {
75
- options.playbackRate = parseFloat(element.dataset.playbackRate);
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
- if (element.dataset.enableMediaSession !== undefined) {
90
- options.enableMediaSession = element.dataset.enableMediaSession === 'true';
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 time in MM:SS format
98
- * @param {number} seconds - Time in seconds
99
- * @returns {string} Formatted time
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 mins = Math.floor(seconds / 60);
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
- * Generate unique ID from URL
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} Base64 encoded ID
241
+ * @returns {string} Unique element-id-safe string
114
242
  */
115
243
  export function generateId(url) {
116
- const str = url || Math.random().toString();
117
- return btoa(str.substring(0, 10)).replace(/[^a-zA-Z0-9]/g, '');
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
- * Extract title from URL
122
- * @param {string} url - Audio URL
123
- * @returns {string} Extracted title
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
- * Merge multiple option objects
140
- * @param {...Object} sources - Option objects to merge
141
- * @returns {Object} Merged options
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
- * Debounce function
159
- * @param {Function} func - Function to debounce
160
- * @param {number} wait - Wait time in ms
161
- * @returns {Function} Debounced function
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
- * Resample array data
179
- * @param {number[]} data - Original data
180
- * @param {number} targetLength - Target length
181
- * @returns {number[]} Resampled data
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;