tiny-essentials 1.26.4 → 1.27.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.
Files changed (120) hide show
  1. package/.github/workflows/node.js.yml +1 -1
  2. package/README.md +0 -1
  3. package/changelog/1/27/0.md +24 -0
  4. package/dist/v1/ColorSafeStringify.min.js +1 -1
  5. package/dist/v1/TinyAdvancedRaffle.min.js +1 -1
  6. package/dist/v1/TinyAfterScrollWatcher.min.js +1 -1
  7. package/dist/v1/TinyAnalogClock.min.js +1 -1
  8. package/dist/v1/TinyArrayComparator.min.js +1 -1
  9. package/dist/v1/TinyArrayPaginator.min.js +1 -1
  10. package/dist/v1/TinyBasicsEs.min.js +1 -1
  11. package/dist/v1/TinyClassManager.min.js +1 -1
  12. package/dist/v1/TinyClipboard.min.js +1 -1
  13. package/dist/v1/TinyColorConverter.min.js +1 -1
  14. package/dist/v1/TinyColorValidator.min.js +1 -1
  15. package/dist/v1/TinyCookieConsent.min.js +1 -1
  16. package/dist/v1/TinyDayNightCycle.min.js +1 -1
  17. package/dist/v1/TinyDomReadyManager.min.js +1 -1
  18. package/dist/v1/TinyDragDropDetector.min.js +1 -1
  19. package/dist/v1/TinyDragger.min.js +1 -1
  20. package/dist/v1/TinyElementObserver.min.js +1 -1
  21. package/dist/v1/TinyEssentials.min.js +1 -1
  22. package/dist/v1/TinyEvents.min.js +1 -1
  23. package/dist/v1/TinyGamepad.min.js +1 -1
  24. package/dist/v1/TinyHtml.min.js +1 -1
  25. package/dist/v1/TinyI18.min.js +1 -1
  26. package/dist/v1/TinyIframeEvents.min.js +1 -1
  27. package/dist/v1/TinyInventory.min.js +1 -1
  28. package/dist/v1/TinyInventoryTrader.min.js +1 -1
  29. package/dist/v1/TinyLevelUp.min.js +1 -1
  30. package/dist/v1/TinyLoadingScreen.min.js +1 -1
  31. package/dist/v1/TinyLocalStorage.min.js +1 -1
  32. package/dist/v1/TinyMaInSys.min.js +1 -1
  33. package/dist/v1/TinyMediaPlayer.min.js +1 -0
  34. package/dist/v1/TinyNeedBar.min.js +1 -1
  35. package/dist/v1/TinyNewWinEvents.min.js +1 -1
  36. package/dist/v1/TinyNotifications.min.js +1 -1
  37. package/dist/v1/TinyNotifyCenter.min.js +1 -1
  38. package/dist/v1/TinyPromiseQueue.min.js +1 -1
  39. package/dist/v1/TinyRadioFm.min.js +1 -0
  40. package/dist/v1/TinyRateLimiter.min.js +1 -1
  41. package/dist/v1/TinySimpleDice.min.js +1 -1
  42. package/dist/v1/TinySmartScroller.min.js +1 -1
  43. package/dist/v1/TinyTextDiffer.min.js +1 -1
  44. package/dist/v1/TinyTextRangeEditor.min.js +1 -1
  45. package/dist/v1/TinyTextarea.min.js +1 -1
  46. package/dist/v1/TinyTimeout.min.js +1 -1
  47. package/dist/v1/TinyToastNotify.min.js +1 -1
  48. package/dist/v1/TinyUploadClicker.min.js +1 -1
  49. package/dist/v1/UltraRandomMsgGen.min.js +1 -1
  50. package/dist/v1/basics/index.cjs +5 -0
  51. package/dist/v1/basics/index.d.mts +5 -1
  52. package/dist/v1/basics/index.mjs +2 -1
  53. package/dist/v1/basics/mediaContent.cjs +644 -0
  54. package/dist/v1/basics/mediaContent.d.mts +296 -0
  55. package/dist/v1/basics/mediaContent.mjs +557 -0
  56. package/dist/v1/build/TinyMediaPlayer.cjs +7 -0
  57. package/dist/v1/build/TinyMediaPlayer.d.mts +3 -0
  58. package/dist/v1/build/TinyMediaPlayer.mjs +2 -0
  59. package/dist/v1/build/TinyRadioFm.cjs +7 -0
  60. package/dist/v1/build/TinyRadioFm.d.mts +3 -0
  61. package/dist/v1/build/TinyRadioFm.mjs +2 -0
  62. package/dist/v1/index.cjs +9 -0
  63. package/dist/v1/index.d.mts +7 -1
  64. package/dist/v1/index.mjs +5 -2
  65. package/dist/v1/libs/ColorSafeStringify.d.mts +1 -1
  66. package/dist/v1/libs/TinyAfterScrollWatcher.cjs +16 -3
  67. package/dist/v1/libs/TinyAfterScrollWatcher.d.mts +7 -3
  68. package/dist/v1/libs/TinyAfterScrollWatcher.mjs +13 -3
  69. package/dist/v1/libs/TinyArrayComparator.cjs +14 -11
  70. package/dist/v1/libs/TinyArrayComparator.d.mts +22 -19
  71. package/dist/v1/libs/TinyArrayComparator.mjs +14 -11
  72. package/dist/v1/libs/TinyArrayPaginator.cjs +23 -16
  73. package/dist/v1/libs/TinyArrayPaginator.d.mts +57 -30
  74. package/dist/v1/libs/TinyArrayPaginator.mjs +22 -16
  75. package/dist/v1/libs/TinyClassManager/TinyPluginInliner.cjs +265 -0
  76. package/dist/v1/libs/TinyClassManager/TinyPluginInliner.d.mts +92 -0
  77. package/dist/v1/libs/TinyClassManager/TinyPluginInliner.mjs +223 -0
  78. package/dist/v1/libs/TinyClassManager.cjs +52 -22
  79. package/dist/v1/libs/TinyClassManager.d.mts +26 -16
  80. package/dist/v1/libs/TinyClassManager.mjs +50 -21
  81. package/dist/v1/libs/TinyColorValidator.d.mts +16 -16
  82. package/dist/v1/libs/TinyDragDropDetector.cjs +9 -34
  83. package/dist/v1/libs/TinyDragDropDetector.d.mts +7 -21
  84. package/dist/v1/libs/TinyDragDropDetector.mjs +9 -24
  85. package/dist/v1/libs/TinyDragger.cjs +5 -5
  86. package/dist/v1/libs/TinyDragger.d.mts +4 -3
  87. package/dist/v1/libs/TinyDragger.mjs +5 -5
  88. package/dist/v1/libs/TinyElementObserver.cjs +6 -21
  89. package/dist/v1/libs/TinyElementObserver.d.mts +6 -15
  90. package/dist/v1/libs/TinyElementObserver.mjs +6 -20
  91. package/dist/v1/libs/TinyGamepad.cjs +4 -6
  92. package/dist/v1/libs/TinyGamepad.d.mts +1 -1
  93. package/dist/v1/libs/TinyHtml/TinyHtmlIcon.d.mts +1 -1
  94. package/dist/v1/libs/TinyHtml/TinyHtmlTemplate.d.mts +1 -1
  95. package/dist/v1/libs/TinyHtml/TinyHtmlTextarea.d.mts +1 -1
  96. package/dist/v1/libs/TinyHtml/index.d.mts +1 -1
  97. package/dist/v1/libs/TinyHtml.cjs +72 -98
  98. package/dist/v1/libs/TinyHtml.d.mts +15 -32
  99. package/dist/v1/libs/TinyHtml.mjs +53 -77
  100. package/dist/v1/libs/TinyInventory.d.mts +1 -1
  101. package/dist/v1/libs/TinyMediaPlayer.cjs +844 -0
  102. package/dist/v1/libs/TinyMediaPlayer.d.mts +306 -0
  103. package/dist/v1/libs/TinyMediaPlayer.mjs +755 -0
  104. package/dist/v1/libs/TinyRadioFm.cjs +1378 -0
  105. package/dist/v1/libs/TinyRadioFm.d.mts +554 -0
  106. package/dist/v1/libs/TinyRadioFm.mjs +1137 -0
  107. package/dist/v1/libs/TinySmartScroller.cjs +39 -26
  108. package/dist/v1/libs/TinySmartScroller.d.mts +16 -62
  109. package/dist/v1/libs/TinySmartScroller.mjs +35 -26
  110. package/dist/v1/libs/UltraRandomMsgGen.d.mts +7 -7
  111. package/docs/v1/README.md +6 -0
  112. package/docs/v1/basics/mediaContent.md +131 -0
  113. package/docs/v1/libs/TinyClassManager/TinyPluginInliner.md +107 -0
  114. package/docs/v1/libs/TinyDragDropDetector.md +5 -9
  115. package/docs/v1/libs/TinyElementObserver.md +3 -6
  116. package/docs/v1/libs/TinyHtml.md +0 -29
  117. package/docs/v1/libs/TinyMediaPlayer.md +100 -0
  118. package/docs/v1/libs/TinyRadioFm.md +141 -0
  119. package/docs/v1/libs/TinySmartScroller.md +3 -3
  120. package/package.json +40 -22
@@ -0,0 +1,644 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @typedef {string|(() => string)} UnknownArtistGetter
5
+ */
6
+
7
+ /**
8
+ * Represents an image attachment template.
9
+ * @template {Uint8Array|string} PictureData
10
+ * @typedef {Object} IPictureTemplate
11
+ * @property {string} format - The MIME type of the image (e.g., 'image/jpeg').
12
+ * @property {PictureData} data - The raw binary data of the image.
13
+ * @property {string} [description] - An optional textual description of the image.
14
+ * @property {string} [type] - The specific type of picture (e.g., 'cover', 'front', 'back').
15
+ * @property {string} [name] - The filename associated with the image.
16
+ */
17
+
18
+ /**
19
+ * Represents an image attachment, such as album art.
20
+ * @typedef {IPictureTemplate<string>} IPicture
21
+ */
22
+
23
+ /**
24
+ * A numeric structure representing track or disk indexing.
25
+ * @typedef {{no: number|null, of: number|null}} MediaNumber
26
+ */
27
+
28
+ /**
29
+ * This metadata structure is modeled template.
30
+ *
31
+ * @template {IPictureTemplate<Uint8Array|string>} IPictureContent
32
+ * @typedef {Object} ContentMetadataTemplate
33
+ * @property {string|null} title - The title of the track.
34
+ * @property {string|null} album - The name of the album.
35
+ * @property {string|null} albumartist - The primary artist of the album.
36
+ * @property {string[]} albumartists - An array of artists associated with the album.
37
+ * @property {string[]} genre - An array of genres associated with the track.
38
+ * @property {string[]} label - The record label.
39
+ * @property {string[]} composer - The composer of the track.
40
+ * @property {number|null} year - The release year.
41
+ * @property {string|null} artist - The primary artist of the track.
42
+ * @property {string[]} artists - An array of artists associated with the track.
43
+ * @property {MediaNumber} disk - Disk information containing the current disk number and total disks.
44
+ * @property {MediaNumber} track - Track information containing the current track number and total tracks.
45
+ * @property {IPictureContent[]} [picture] - An array of picture objects containing album art.
46
+ */
47
+
48
+ /**
49
+ * This metadata structure is modeled after the standard output of the
50
+ * `music-metadata@11.13.0` npm package.
51
+ *
52
+ * @typedef {ContentMetadataTemplate<IPicture>} MediaContentMetadata
53
+ */
54
+
55
+ /**
56
+ * The core properties required for any content item within the media system.
57
+ * @typedef {Object} MediaContentBase
58
+ * @property {string} id - Unique identifier.
59
+ * @property {string} title - Name of the track/message.
60
+ * @property {string} artist - Artist or speaker name.
61
+ * @property {number} duration - Duration in milliseconds.
62
+ * @property {string} url - Source URL/Path.
63
+ * @property {number} [weight=1] - Probability multiplier for random selection mode.
64
+ */
65
+
66
+ /**
67
+ * The final content object used within the media, combining
68
+ * core playback properties with rich metadata.
69
+ *
70
+ * @typedef {MediaContentBase & MediaContentMetadata} MediaContent
71
+ */
72
+
73
+ //////////////////////////////////////////////////////////////////
74
+
75
+ /**
76
+ * A promise that resolves to an object containing the extracted metadata.
77
+ * @callback ParseMediaContentMetadata
78
+ * @param {Blob} data - The raw file blob.
79
+ * @returns {Promise<{ common: Partial<ContentMetadataTemplate<IPictureTemplate<Uint8Array|string>>> }>} A promise resolving to the common metadata properties.
80
+ */
81
+
82
+ /**
83
+ * @typedef {Object} LoadingMediaProgress
84
+ * @property {'loading'|'success'} status - The current status of the operation.
85
+ * @property {LoadingProgressStage} stage - The current execution stage.
86
+ * @property {ProgressEvent<EventTarget>} [event] - The current loading event.
87
+ * @property {string} url - The URL being processed.
88
+ */
89
+
90
+ /**
91
+ * @typedef {'INITIALIZING'|'DOWNLOADING'|'METADATA_LOADED'|'EXTRACTING_ID3'|'COMPLETE'} LoadingProgressStage
92
+ */
93
+
94
+ /**
95
+ * @typedef {'INITIALIZING'|'DOWNLOADING'|'METADATA'|'ID3'|'UNKNOWN'} LoadingErrorStage
96
+ */
97
+
98
+ /**
99
+ * @typedef {Object} MediaLoadingErrorData
100
+ * @property {Error} error - The original error object.
101
+ * @property {string} url - The URL that failed.
102
+ * @property {LoadingErrorStage} stage - The stage where it failed.
103
+ */
104
+
105
+ //////////////////////////////////////////////////////////////////
106
+
107
+ /** @type {Map<string, number>} */
108
+ const _blobCounter = new Map();
109
+
110
+ /** @returns {MediaContentBase} */
111
+ const getMediaContentBase = () => ({
112
+ id: '',
113
+ title: '',
114
+ artist: '',
115
+ url: '',
116
+ duration: 0,
117
+ weight: 1,
118
+ });
119
+
120
+ /** @returns {MediaContentMetadata} */
121
+ const getMediaContentMetadata = () => ({
122
+ title: null,
123
+ album: null,
124
+ albumartist: null,
125
+ albumartists: [],
126
+ genre: [],
127
+ label: [],
128
+ composer: [],
129
+ year: null,
130
+ artist: null,
131
+ artists: [],
132
+ disk: { no: null, of: null },
133
+ track: { no: null, of: null },
134
+ picture: [],
135
+ });
136
+
137
+ /**
138
+ * Custom error class to provide detailed context during the content preparation process.
139
+ * @extends Error
140
+ */
141
+ class MediaLoadingError extends Error {
142
+ /**
143
+ * @param {string} message - Human-readable error message.
144
+ * @param {string} url - The URL that caused the error.
145
+ * @param {LoadingErrorStage} stage - The stage where the error occurred.
146
+ */
147
+ constructor(message, url, stage) {
148
+ super(message);
149
+ this.name = 'MediaLoadingError';
150
+ this.url = url;
151
+ this.stage = stage;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Internal helper to generate a deterministic ID from a string.
157
+ * @param {string} str
158
+ * @returns {Promise<string>}
159
+ * @private
160
+ */
161
+ const generateSimpleHash = async (str) => {
162
+ const msgUint8 = new TextEncoder().encode(str);
163
+ const hashBuffer = await crypto.subtle.digest('SHA-1', msgUint8);
164
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
165
+ // Return first 8 chars of the hex hash for a clean ID
166
+ return hashArray
167
+ .map((b) => b.toString(16).padStart(2, '0'))
168
+ .join('')
169
+ .substring(0, 8);
170
+ };
171
+
172
+ /**
173
+ * Helper to convert Uint8Array or Base64 string directly into a high performance Blob URL.
174
+ * @param {Uint8Array|string} data
175
+ * @param {string} format
176
+ * @returns {string} The generated Blob URL or original string if already valid.
177
+ * @private
178
+ */
179
+ const convertToBlobUrl = (data, format = 'image/jpeg') => {
180
+ const createBlobCounter = (/** @type {Blob} */ blob) => {
181
+ const url = URL.createObjectURL(blob);
182
+ const blobUrlUsage = _blobCounter.get(url);
183
+ _blobCounter.set(url, typeof blobUrlUsage === 'number' ? blobUrlUsage + 1 : 1);
184
+ return url;
185
+ };
186
+
187
+ if (data instanceof Uint8Array) {
188
+ // @ts-ignore
189
+ const blob = new Blob([data], { type: format });
190
+ return createBlobCounter(blob);
191
+ } else if (typeof data === 'string' && data.startsWith('data:')) {
192
+ const base64Part = data.split(',')[1];
193
+ const byteString = atob(base64Part);
194
+ const ab = new Uint8Array(byteString.length);
195
+ for (let i = 0; i < byteString.length; i++) {
196
+ ab[i] = byteString.charCodeAt(i);
197
+ }
198
+ const blob = new Blob([ab], { type: format });
199
+ return createBlobCounter(blob);
200
+ }
201
+ return typeof data === 'string' ? data : '';
202
+ };
203
+
204
+ /**
205
+ * Asynchronous helper to convert a Blob URL back to Base64 (Date URL) at export time.
206
+ * @param {string} url
207
+ * @returns {Promise<string>}
208
+ * @private
209
+ */
210
+ const blobUrlToBase64 = async (url) => {
211
+ if (typeof url !== 'string' || !url.startsWith('blob:')) return url;
212
+ try {
213
+ const response = await fetch(url);
214
+ const blob = await response.blob();
215
+ return await new Promise((resolve, reject) => {
216
+ const reader = new FileReader();
217
+ reader.onloadend = () => resolve(String(reader.result));
218
+ reader.onerror = reject;
219
+ reader.readAsDataURL(blob);
220
+ });
221
+ } catch (e) {
222
+ console.warn(`[TinyAudioUtils] Failed to convert Blob URL to Base64 on export: ${url}`, e);
223
+ return url;
224
+ }
225
+ };
226
+
227
+ /**
228
+ * Safely revokes Blob URLs to prevent memory leaks from createObjectURL.
229
+ * @param {MediaContent} content
230
+ * @private
231
+ */
232
+ const revokeContentUrls = (content) => {
233
+ if (content && Array.isArray(content.picture)) {
234
+ content.picture.forEach((pic) => {
235
+ if (typeof pic.data === 'string' && pic.data.startsWith('blob:')) {
236
+ const blobUrlUsage = _blobCounter.get(pic.data) ?? 0;
237
+ if (blobUrlUsage > 1) _blobCounter.set(pic.data, blobUrlUsage - 1);
238
+ else _blobCounter.delete(pic.data);
239
+ if (blobUrlUsage <= 1) URL.revokeObjectURL(pic.data);
240
+ }
241
+ });
242
+ }
243
+ };
244
+
245
+ /**
246
+ * Central logic of metadata validation.
247
+ * @param {Partial<ContentMetadataTemplate<IPictureTemplate<string | Uint8Array<ArrayBufferLike>>>>} common - The object to be validated.
248
+ * @param {boolean} isPartial - If true, properties can be undefined.
249
+ */
250
+ const validateMediaContent = (common, isPartial) => {
251
+ // Helper to check if the property can be ignored in partial mode
252
+ const isUndefinedAllowed = (/** @type {any} */ v) => isPartial && typeof v === 'undefined';
253
+
254
+ const isString = (/** @type {string | null | undefined} */ v) =>
255
+ isUndefinedAllowed(v) || typeof v === 'string' || v === null;
256
+
257
+ const isNumber = (/** @type {number | null | undefined}} */ v) =>
258
+ isUndefinedAllowed(v) || typeof v === 'number' || v === null;
259
+
260
+ const isArray = (
261
+ /** @type {string[] | IPictureTemplate<string|Uint8Array>[] | undefined} */ v,
262
+ /** @type {(value: any, index: number, array: any[]) => any} */ valueValidator,
263
+ ) => {
264
+ if (isUndefinedAllowed(v)) return true;
265
+ if (Array.isArray(v) && v.every(valueValidator)) return true;
266
+ return false;
267
+ };
268
+
269
+ // Validate Primitives
270
+ if (!isString(common.title))
271
+ throw new TypeError('Invalid metadata: "title" must be a string or null.');
272
+ if (!isString(common.album))
273
+ throw new TypeError('Invalid metadata: "album" must be a string or null.');
274
+ if (!isString(common.albumartist))
275
+ throw new TypeError('Invalid metadata: "albumartist" must be a string or null.');
276
+ if (!isString(common.artist))
277
+ throw new TypeError('Invalid metadata: "artist" must be a string or null.');
278
+ if (!isNumber(common.year))
279
+ throw new TypeError('Invalid metadata: "year" must be a number or null.');
280
+
281
+ // Validate Arrays
282
+ if (!isArray(common.albumartists, (value) => typeof value === 'string'))
283
+ throw new TypeError('Invalid metadata: "albumartists" must be an array of string.');
284
+ if (!isArray(common.genre, (value) => typeof value === 'string'))
285
+ throw new TypeError('Invalid metadata: "genre" must be an array of string.');
286
+ if (!isArray(common.label, (value) => typeof value === 'string'))
287
+ throw new TypeError('Invalid metadata: "label" must be an array of string.');
288
+ if (!isArray(common.composer, (value) => typeof value === 'string'))
289
+ throw new TypeError('Invalid metadata: "composer" must be an array of string.');
290
+ if (!isArray(common.artists, (value) => typeof value === 'string'))
291
+ throw new TypeError('Invalid metadata: "artists" must be an array of string.');
292
+ if (
293
+ !isArray(
294
+ common.picture,
295
+ (value) =>
296
+ (typeof value.description === 'undefined' || typeof value.description === 'string') &&
297
+ (typeof value.name === 'undefined' || typeof value.name === 'string') &&
298
+ (typeof value.type === 'undefined' || typeof value.type === 'string') &&
299
+ typeof value.format === 'string' &&
300
+ (value.data instanceof Uint8Array || typeof value.data === 'string'),
301
+ )
302
+ )
303
+ throw new TypeError('Invalid metadata: "picture" must be an array of pictures.');
304
+
305
+ /**
306
+ * Validate Nested Objects (Disk and Track)
307
+ * @param {MediaNumber|null} [info]
308
+ * @param {string} [name]
309
+ */
310
+ const validateTrackInfo = (info, name) => {
311
+ if (isUndefinedAllowed(info)) return; // Ignores if partial
312
+
313
+ if (info === undefined || info === null) {
314
+ throw new TypeError(`Invalid metadata: "${name}" is required.`);
315
+ }
316
+
317
+ if (typeof info !== 'object')
318
+ throw new TypeError(`Invalid metadata: "${name}" must be an object or null.`);
319
+ if (typeof info.no !== 'number' && info.no !== null)
320
+ throw new TypeError(`Invalid metadata: "${name}.no" must be a number or null.`);
321
+ if (typeof info.of !== 'number' && info.of !== null)
322
+ throw new TypeError(`Invalid metadata: "${name}.of" must be a number or null.`);
323
+ };
324
+
325
+ validateTrackInfo(common.disk, 'disk');
326
+ validateTrackInfo(common.track, 'track');
327
+ };
328
+
329
+ /**
330
+ * Helper to validate types within the media content metadata object.
331
+ * This ensures that if a property is present, it matches the expected type.
332
+ * @param {ContentMetadataTemplate<IPictureTemplate<string | Uint8Array<ArrayBufferLike>>>} common
333
+ */
334
+ const valMediaContentMetadata = (common) => validateMediaContent(common, false);
335
+
336
+ /**
337
+ * Helper to validate types within the media content metadata object.
338
+ * Allows the absence of properties (useful for updates/patch).
339
+ * @param {Partial<ContentMetadataTemplate<IPictureTemplate<string | Uint8Array<ArrayBufferLike>>>>} common
340
+ */
341
+ const valMediaContentMetadataPartial = (common) => validateMediaContent(common, true);
342
+
343
+ /**
344
+ * Downloads an audio file from a URL and extracts its ID3/metadata tags.
345
+ *
346
+ * @param {string} url - The full URL of the audio file to be downloaded.
347
+ * @param {ParseMediaContentMetadata} parseFile - The function used to parse the file data.
348
+ * @returns {Promise<MediaContentMetadata>} A promise that resolves to an object containing the extracted metadata.
349
+ * @throws {TypeError} If the provided `url` is not a string or `parseFile` is not a function.
350
+ * @throws {Error} If the network request fails or the parsing process encounters an error.
351
+ */
352
+ const extractMediaId3Tags = async (url, parseFile) => {
353
+ // Argument Validation
354
+ if (typeof url !== 'string')
355
+ throw new TypeError(`Expected url to be a string, but received ${typeof url}.`);
356
+ if (typeof parseFile !== 'function')
357
+ throw new TypeError(`Expected parseFile to be a function, but received ${typeof parseFile}.`);
358
+
359
+ try {
360
+ // 1. Download the file from the provided URL
361
+ const response = await fetch(url);
362
+
363
+ if (!response.ok)
364
+ throw new Error(`Failed to fetch audio file: ${response.status} ${response.statusText}`);
365
+
366
+ // 2. Convert the response into a Blob so the parser can read it
367
+ const blob = await response.blob();
368
+
369
+ // 3. Use the provided parser function on the Blob
370
+ const metadata = await parseFile(blob);
371
+
372
+ // 4. Complete Validation of the parsed metadata structure
373
+ if (!metadata || typeof metadata.common !== 'object')
374
+ throw new TypeError('Invalid metadata: "common" property is missing or not an object.');
375
+
376
+ const common = metadata.common;
377
+ valMediaContentMetadataPartial(common);
378
+
379
+ // 5. Return the specific metadata fields requested
380
+ // We structure the return to match the MediaContentMetadata typedef
381
+ return {
382
+ title: common?.title ?? null,
383
+ album: common?.album ?? null,
384
+ albumartist: common?.albumartist ?? null,
385
+ albumartists: common?.albumartists ?? [],
386
+ genre: common?.genre ?? [],
387
+ label: common?.label ?? [],
388
+ composer: common?.composer ?? [],
389
+ year: common?.year ?? null,
390
+ artist: common?.artist ?? null,
391
+ artists: common?.artists ?? [],
392
+ disk: common?.disk ? { no: common.disk.no, of: common.disk.of } : { no: null, of: null },
393
+ track: common?.track ? { no: common.track.no, of: common.track.of } : { no: null, of: null },
394
+ picture:
395
+ common?.picture?.map((value) => ({
396
+ ...value,
397
+ data: convertToBlobUrl(value.data, value.format),
398
+ })) ?? [],
399
+ };
400
+ } catch (error) {
401
+ // Re-throwing the error allows the caller to handle specific failure cases
402
+ throw error;
403
+ }
404
+ };
405
+
406
+ /**
407
+ * A Static Factory Method that prepares a MediaContent object by
408
+ * extracting metadata from an audio source.
409
+ *
410
+ * @param {string | HTMLMediaElement} source - A URL string or an existing Audio object.
411
+ * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata that overrides automatic extraction.
412
+ * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata that overrides automatic extraction.
413
+ * @param {ParseMediaContentMetadata} [parseFile] - Private helper to interface with parseFile.
414
+ * @param {Object} [callbacks={}] - Callbacks for monitoring the loading process.
415
+ * @param {(progress: LoadingMediaProgress) => void} [callbacks.onProgress] - Callback triggered on stage changes.
416
+ * @param {(error: MediaLoadingErrorData) => void} [callbacks.onError] - Callback triggered when a non-fatal or fatal error occurs.
417
+ * @param {UnknownArtistGetter} unknownArtist
418
+ * @returns {Promise<MediaContent>} A promise that resolves to a valid MediaContent object.
419
+ * @throws {MediaLoadingError} If the preparation process fails at any stage.
420
+ *
421
+ * @example
422
+ * // Usage with URL
423
+ * import { parseBlob } from 'music-metadata';
424
+ * const track = await parseMediaMetadata('/assets/song.mp3', {}, { title: 'My Song', artist: 'Artist' }, parseBlob);
425
+ * media.add('music', track);
426
+ *
427
+ * @example
428
+ * // Usage with Audio Object
429
+ * import { parseBlob } from 'music-metadata';
430
+ * const audio = new Audio();
431
+ * audio.src = '/assets/song.mp3';
432
+ * const track = await parseMediaMetadata(audio, {}, {}, parseBlob);
433
+ * media.add('music', track);
434
+ *
435
+ * @example
436
+ * // Usage with tracking
437
+ * const track = await parseMediaMetadata(
438
+ * '/assets/song.mp3',
439
+ * {},
440
+ * {},
441
+ * parseBlob,
442
+ * {
443
+ * onProgress: (p) => console.log(`[${p.stage}] ${p.status}`),
444
+ * onError: (e) => console.error(`Failed at ${e.stage} for ${e.url}: ${e.error.message}`)
445
+ * }
446
+ * );
447
+ */
448
+ const parseMediaMetadata = async (
449
+ source,
450
+ defaultMetadata = {},
451
+ metadata = {},
452
+ parseFile = (url) => {
453
+ return new Promise((resolve, reject) => reject(new TypeError('parseFile library not found.')));
454
+ },
455
+ callbacks = {},
456
+ unknownArtist = 'null',
457
+ ) => {
458
+ // Argument Validation
459
+ if (typeof source !== 'string' && !(source instanceof HTMLMediaElement))
460
+ throw new TypeError('Source must be a string or an HTMLMediaElement.');
461
+
462
+ if (callbacks.onProgress && typeof callbacks.onProgress !== 'function')
463
+ throw new TypeError('callbacks.onProgress must be a function.');
464
+ if (callbacks.onError && typeof callbacks.onError !== 'function')
465
+ throw new TypeError('callbacks.onError must be a function.');
466
+
467
+ /** @type {HTMLMediaElement} */
468
+ let audio;
469
+ /** @type {string} */
470
+ let url = '';
471
+
472
+ /**
473
+ * @param {LoadingProgressStage} stage
474
+ * @param {ProgressEvent<EventTarget>} [event]
475
+ */
476
+ const notifyProgress = (stage, event) => {
477
+ if (callbacks.onProgress) {
478
+ callbacks.onProgress({
479
+ event,
480
+ status: 'loading',
481
+ stage,
482
+ url: url || (source instanceof HTMLMediaElement ? source.src : 'unknown'),
483
+ });
484
+ }
485
+ };
486
+
487
+ const notifyError = (/** @type {Error} */ error, /** @type {LoadingErrorStage} */ stage) => {
488
+ if (callbacks.onError) {
489
+ callbacks.onError({
490
+ error,
491
+ url: url || (source instanceof HTMLMediaElement ? source.src : 'unknown'),
492
+ stage,
493
+ });
494
+ }
495
+ };
496
+
497
+ try {
498
+ // 1. Normalize Source
499
+ notifyProgress('INITIALIZING');
500
+ if (typeof source === 'string') {
501
+ url = source;
502
+ audio = new Audio(url);
503
+ } else {
504
+ audio = source;
505
+ url = audio.src;
506
+ }
507
+
508
+ // 2. Wait for audio metadata and monitor download progress
509
+ try {
510
+ await new Promise((resolve, reject) => {
511
+ /**
512
+ * Listener for the 'progress' event (detects actual data downloading)
513
+ * @type {(this: HTMLMediaElement, ev: ProgressEvent<EventTarget>) => any}
514
+ */
515
+ const onProgress = (event) => notifyProgress('DOWNLOADING', event);
516
+
517
+ // Listener for 'loadedmetadata' (duration is now available)
518
+ const onMetadata = () => {
519
+ cleanup();
520
+ resolve(undefined);
521
+ };
522
+
523
+ // Listener for errors
524
+ const onError = (/** @type {{ message: any; }} */ err) => {
525
+ cleanup();
526
+ reject(new Error(`HTMLMediaElement failed to load: ${err.message || 'Unknown error'}`));
527
+ };
528
+
529
+ const cleanup = () => {
530
+ audio.removeEventListener('progress', onProgress);
531
+ audio.removeEventListener('loadedmetadata', onMetadata);
532
+ audio.removeEventListener('error', onError);
533
+ };
534
+
535
+ audio.addEventListener('progress', onProgress);
536
+ audio.addEventListener('loadedmetadata', onMetadata);
537
+ audio.addEventListener('error', onError);
538
+
539
+ // If metadata is already there (e.g. cached by browser)
540
+ if (audio.readyState >= 1) {
541
+ cleanup();
542
+ resolve(undefined);
543
+ }
544
+ });
545
+
546
+ notifyProgress('METADATA_LOADED');
547
+ } catch (err) {
548
+ throw new MediaLoadingError(
549
+ err instanceof Error ? err.message : 'UNKNOWN ERROR',
550
+ url,
551
+ 'METADATA',
552
+ );
553
+ }
554
+
555
+ // 3. Initialize Base Data (Core properties required for the system)
556
+ const baseData = {
557
+ id: metadata.id || (await generateSimpleHash(url)),
558
+ duration: Math.floor(audio.duration * 1000), // Convert to ms for our class
559
+ url: url,
560
+ weight: metadata.weight ?? 1,
561
+ };
562
+
563
+ // 4. Automatic Metadata Extraction (ID3 Tags)
564
+ /** @type {Partial<MediaContentMetadata>} */
565
+ let extractedMetadata = {};
566
+ notifyProgress('EXTRACTING_ID3');
567
+ try {
568
+ extractedMetadata = await extractMediaId3Tags(url, parseFile);
569
+ } catch (err) {
570
+ // We treat ID3 failure as a non-fatal error for the whole process,
571
+ // but we still notify the developer via onError.
572
+ notifyError(err instanceof Error ? err : new Error('Unknown Error'), 'ID3');
573
+ console.warn(`[TinyAudioUtils] ID3 extraction failed for ${url}. Falling back to filename.`);
574
+ }
575
+
576
+ /**
577
+ * Extracts a readable filename from a URL without the extension.
578
+ * @param {string} url
579
+ * @returns {string}
580
+ */
581
+ const getFallbackTitleFromUrl = (url) => {
582
+ try {
583
+ // Remove query params or hashes, get the last segment, and strip extension
584
+ const filename = url.split(/[?#]/)[0].split('/').pop();
585
+ return (filename ?? '').replace(/\.[^/.]+$/, '') || 'Unknown Track';
586
+ } catch {
587
+ return 'Unknown Track';
588
+ }
589
+ };
590
+
591
+ // 5. Final Merge Logic
592
+ // Priority: Manual Metadata (highest) > Extracted ID3 > Default values
593
+ const finalContent = {
594
+ ...baseData,
595
+ ...defaultMetadata,
596
+ ...extractedMetadata,
597
+ ...metadata,
598
+ // Explicitly ensure title and artist are resolved from the hierarchy
599
+ title: extractedMetadata.title || metadata.title || getFallbackTitleFromUrl(url),
600
+ artist:
601
+ extractedMetadata.artist ||
602
+ metadata.artist ||
603
+ (typeof unknownArtist === 'string' ? unknownArtist : String(unknownArtist())),
604
+ };
605
+
606
+ // Notify Success
607
+ if (callbacks.onProgress) {
608
+ callbacks.onProgress({
609
+ status: 'success',
610
+ stage: 'COMPLETE',
611
+ url: url,
612
+ });
613
+ }
614
+
615
+ return /** @type {MediaContent} */ (finalContent);
616
+ } catch (err) {
617
+ // If it's already a MediaLoadingError, re-throw it.
618
+ // Otherwise, wrap it.
619
+ if (err instanceof MediaLoadingError) {
620
+ throw err;
621
+ } else {
622
+ const wrappedError = new MediaLoadingError(
623
+ err instanceof Error ? err.message : 'UNKNOWN ERROR',
624
+ url,
625
+ 'INITIALIZING',
626
+ );
627
+ notifyError(wrappedError, 'INITIALIZING');
628
+ throw wrappedError;
629
+ }
630
+ }
631
+ };
632
+
633
+ exports.MediaLoadingError = MediaLoadingError;
634
+ exports._blobCounter = _blobCounter;
635
+ exports.blobUrlToBase64 = blobUrlToBase64;
636
+ exports.convertToBlobUrl = convertToBlobUrl;
637
+ exports.extractMediaId3Tags = extractMediaId3Tags;
638
+ exports.generateSimpleHash = generateSimpleHash;
639
+ exports.getMediaContentBase = getMediaContentBase;
640
+ exports.getMediaContentMetadata = getMediaContentMetadata;
641
+ exports.parseMediaMetadata = parseMediaMetadata;
642
+ exports.revokeContentUrls = revokeContentUrls;
643
+ exports.valMediaContentMetadata = valMediaContentMetadata;
644
+ exports.valMediaContentMetadataPartial = valMediaContentMetadataPartial;