tiny-essentials 1.28.7 → 1.28.8
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/changelog/1/28/8.md +17 -0
- package/dist/v1/TinyBasicsEs.min.js +1 -1
- package/dist/v1/TinyDebugger.min.js +1 -0
- package/dist/v1/TinyEssentials.min.js +1 -1
- package/dist/v1/TinyMediaPlayer.min.js +1 -1
- package/dist/v1/TinyRadioFm.min.js +1 -1
- package/dist/v1/TinyUploadClicker.min.js +1 -1
- package/dist/v1/basics/browserDetector.cjs +228 -0
- package/dist/v1/basics/browserDetector.d.mts +66 -0
- package/dist/v1/basics/browserDetector.mjs +207 -0
- package/dist/v1/basics/index.cjs +6 -0
- package/dist/v1/basics/index.d.mts +2 -1
- package/dist/v1/basics/index.mjs +2 -1
- package/dist/v1/build/TinyDebugger.cjs +7 -0
- package/dist/v1/build/TinyDebugger.d.mts +3 -0
- package/dist/v1/build/TinyDebugger.mjs +2 -0
- package/dist/v1/index.cjs +8 -0
- package/dist/v1/index.d.mts +3 -1
- package/dist/v1/index.mjs +4 -2
- package/dist/v1/libs/TinyDebugger.cjs +380 -0
- package/dist/v1/libs/TinyDebugger.d.mts +132 -0
- package/dist/v1/libs/TinyDebugger.mjs +351 -0
- package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.cjs +39 -3
- package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.d.mts +10 -0
- package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.mjs +35 -3
- package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.cjs +39 -3
- package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.d.mts +10 -0
- package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.mjs +35 -3
- package/dist/v1/libs/TinyMediaPlayer/Mock.cjs +333 -0
- package/dist/v1/libs/TinyMediaPlayer/Mock.d.mts +154 -0
- package/dist/v1/libs/TinyMediaPlayer/Mock.mjs +296 -0
- package/dist/v1/libs/TinyMediaPlayer/SoundCloud.cjs +30 -0
- package/dist/v1/libs/TinyMediaPlayer/SoundCloud.d.mts +10 -0
- package/dist/v1/libs/TinyMediaPlayer/SoundCloud.mjs +26 -0
- package/dist/v1/libs/TinyMediaPlayer/Youtube.cjs +39 -3
- package/dist/v1/libs/TinyMediaPlayer/Youtube.d.mts +10 -0
- package/dist/v1/libs/TinyMediaPlayer/Youtube.mjs +32 -3
- package/dist/v1/libs/TinyMediaPlayer/index.cjs +18 -0
- package/dist/v1/libs/TinyMediaPlayer/index.d.mts +10 -0
- package/dist/v1/libs/TinyMediaPlayer/index.mjs +16 -0
- package/dist/v1/libs/TinyMediaPlayer.cjs +156 -31
- package/dist/v1/libs/TinyMediaPlayer.d.mts +43 -7
- package/dist/v1/libs/TinyMediaPlayer.mjs +148 -31
- package/dist/v1/libs/TinyPromiseQueue.cjs +6 -4
- package/dist/v1/libs/TinyPromiseQueue.d.mts +8 -6
- package/dist/v1/libs/TinyPromiseQueue.mjs +6 -4
- package/dist/v1/libs/TinyRadioFm.cjs +271 -138
- package/dist/v1/libs/TinyRadioFm.d.mts +67 -3
- package/dist/v1/libs/TinyRadioFm.mjs +239 -123
- package/docs/v1/libs/TinyMediaPlayer.md +25 -21
- package/docs/v1/libs/TinyRadioFm.md +25 -3
- package/package.json +9 -1
|
@@ -133,10 +133,18 @@ export type RadioConfig = {
|
|
|
133
133
|
* - Max times a music track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
|
|
134
134
|
*/
|
|
135
135
|
musicMaxConsecutive: number;
|
|
136
|
+
/**
|
|
137
|
+
* - Min times a music track must repeat consecutively (1 = no mandatory repetition).
|
|
138
|
+
*/
|
|
139
|
+
musicMinConsecutive: number;
|
|
136
140
|
/**
|
|
137
141
|
* - Max times a voice track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
|
|
138
142
|
*/
|
|
139
143
|
voiceMaxConsecutive: number;
|
|
144
|
+
/**
|
|
145
|
+
* - Min times a voice track must repeat consecutively (1 = no mandatory repetition).
|
|
146
|
+
*/
|
|
147
|
+
voiceMinConsecutive: number;
|
|
140
148
|
};
|
|
141
149
|
export type CycleBlockData = MediaContent & {
|
|
142
150
|
cycleStart: number;
|
|
@@ -151,6 +159,22 @@ export type CycleBlock = {
|
|
|
151
159
|
* - Total duration of the cycle block in ms.
|
|
152
160
|
*/
|
|
153
161
|
duration: number;
|
|
162
|
+
/**
|
|
163
|
+
* - ID of the last music played in this block.
|
|
164
|
+
*/
|
|
165
|
+
lastMusicId: string | null;
|
|
166
|
+
/**
|
|
167
|
+
* - Current consecutive count for music.
|
|
168
|
+
*/
|
|
169
|
+
musicConsecutiveCount: number;
|
|
170
|
+
/**
|
|
171
|
+
* - ID of the last voice played in this block.
|
|
172
|
+
*/
|
|
173
|
+
lastVoiceId: string | null;
|
|
174
|
+
/**
|
|
175
|
+
* - Current consecutive count for voice.
|
|
176
|
+
*/
|
|
177
|
+
voiceConsecutiveCount: number;
|
|
154
178
|
};
|
|
155
179
|
export type CycleLocation = {
|
|
156
180
|
/**
|
|
@@ -264,7 +288,9 @@ export type TinyRadioFmImport = {
|
|
|
264
288
|
* @property {number} voiceMin - Minimum amount of voice messages to play if voiceAfterMusic is true.
|
|
265
289
|
* @property {number} voiceMax - Maximum amount of voice messages to play.
|
|
266
290
|
* @property {number} musicMaxConsecutive - Max times a music track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
|
|
291
|
+
* @property {number} musicMinConsecutive - Min times a music track must repeat consecutively (1 = no mandatory repetition).
|
|
267
292
|
* @property {number} voiceMaxConsecutive - Max times a voice track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
|
|
293
|
+
* @property {number} voiceMinConsecutive - Min times a voice track must repeat consecutively (1 = no mandatory repetition).
|
|
268
294
|
*/
|
|
269
295
|
/**
|
|
270
296
|
* An extension of MediaContent that includes temporal boundaries within a generated cycle.
|
|
@@ -275,6 +301,10 @@ export type TinyRadioFmImport = {
|
|
|
275
301
|
* @typedef {Object} CycleBlock
|
|
276
302
|
* @property {CycleBlockData[]} items - Items belonging to this cycle.
|
|
277
303
|
* @property {number} duration - Total duration of the cycle block in ms.
|
|
304
|
+
* @property {string|null} lastMusicId - ID of the last music played in this block.
|
|
305
|
+
* @property {number} musicConsecutiveCount - Current consecutive count for music.
|
|
306
|
+
* @property {string|null} lastVoiceId - ID of the last voice played in this block.
|
|
307
|
+
* @property {number} voiceConsecutiveCount - Current consecutive count for voice.
|
|
278
308
|
*/
|
|
279
309
|
/**
|
|
280
310
|
* Information about the location of a specific cycle within the absolute timeline.
|
|
@@ -305,8 +335,8 @@ declare class TinyRadioFm extends EventEmitter {
|
|
|
305
335
|
* extracting metadata from an audio source.
|
|
306
336
|
*
|
|
307
337
|
* @param {string | HTMLMediaElement} source - A URL string or an existing Audio object.
|
|
308
|
-
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata
|
|
309
|
-
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata
|
|
338
|
+
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata.
|
|
339
|
+
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata.
|
|
310
340
|
* @param {ParseMediaContentMetadata} [parseFile] - Private helper to interface with parseFile.
|
|
311
341
|
* @param {Object} [callbacks={}] - Callbacks for monitoring the loading process.
|
|
312
342
|
* @param {(progress: LoadingMediaProgress) => void} [callbacks.onProgress] - Callback triggered on stage changes.
|
|
@@ -417,6 +447,30 @@ declare class TinyRadioFm extends EventEmitter {
|
|
|
417
447
|
* @throws {TypeError|RangeError} If the new configuration is invalid.
|
|
418
448
|
*/
|
|
419
449
|
set config(config: RadioConfig);
|
|
450
|
+
/**
|
|
451
|
+
* Gets whether Blob URLs for media content
|
|
452
|
+
* should be converted to Base64 during export and restored during import.
|
|
453
|
+
* @returns {boolean}
|
|
454
|
+
*/
|
|
455
|
+
get optimizeMediaMemory(): boolean;
|
|
456
|
+
/**
|
|
457
|
+
* Sets whether Blob URLs should be converted to Base64 during export and restored during import.
|
|
458
|
+
* @param {boolean} value
|
|
459
|
+
* @throws {TypeError} If value is not a boolean.
|
|
460
|
+
*/
|
|
461
|
+
set optimizeMediaMemory(value: boolean);
|
|
462
|
+
/**
|
|
463
|
+
* Gets whether Blob URLs for pictures
|
|
464
|
+
* should be converted to Base64 during export and restored during import.
|
|
465
|
+
* @returns {boolean}
|
|
466
|
+
*/
|
|
467
|
+
get optimizePictureMemory(): boolean;
|
|
468
|
+
/**
|
|
469
|
+
* Sets whether Blob URLs should be converted to Base64 during export and restored during import.
|
|
470
|
+
* @param {boolean} value
|
|
471
|
+
* @throws {TypeError} If value is not a boolean.
|
|
472
|
+
*/
|
|
473
|
+
set optimizePictureMemory(value: boolean);
|
|
420
474
|
/**
|
|
421
475
|
* Initializes the radio system.
|
|
422
476
|
* @param {TinyRadioFmImport|null} [initialData=null] - JSON object to hydrate the radio state.
|
|
@@ -424,6 +478,16 @@ declare class TinyRadioFm extends EventEmitter {
|
|
|
424
478
|
* @throws {TypeError} If initialData is not an object or null, or if seed is not a number.
|
|
425
479
|
*/
|
|
426
480
|
constructor(initialData?: TinyRadioFmImport | null, seed?: number);
|
|
481
|
+
/**
|
|
482
|
+
* Converts new content base64 instantly to the blob url.
|
|
483
|
+
* @param {MediaContent} data
|
|
484
|
+
*/
|
|
485
|
+
_ccBase64ToBlobUrl(data: MediaContent): void;
|
|
486
|
+
/**
|
|
487
|
+
* Restores the content blob instantly to the base64 url.
|
|
488
|
+
* @param {MediaContent} data
|
|
489
|
+
*/
|
|
490
|
+
_rcblobUrlToBase64(data: MediaContent): Promise<void>;
|
|
427
491
|
/**
|
|
428
492
|
* Adds new content instantly to the radio sequence.
|
|
429
493
|
* @param {'music'|'voice'|'custom'} type - The category of the content.
|
|
@@ -477,7 +541,7 @@ declare class TinyRadioFm extends EventEmitter {
|
|
|
477
541
|
*/
|
|
478
542
|
searchCustomPositions(): CustomPosition[];
|
|
479
543
|
/**
|
|
480
|
-
* Process a content list, waiting to convert the images from Blob URL to Base64.
|
|
544
|
+
* Process a content list, waiting to convert the images and content URLs from Blob URL to Base64.
|
|
481
545
|
* @param {MediaContent[]} list
|
|
482
546
|
* @returns {Promise<MediaContent[]>}
|
|
483
547
|
* @private
|
|
@@ -73,7 +73,9 @@ const checkDestroy = createCheckDestroyed('TinyRadioFm');
|
|
|
73
73
|
* @property {number} voiceMin - Minimum amount of voice messages to play if voiceAfterMusic is true.
|
|
74
74
|
* @property {number} voiceMax - Maximum amount of voice messages to play.
|
|
75
75
|
* @property {number} musicMaxConsecutive - Max times a music track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
|
|
76
|
+
* @property {number} musicMinConsecutive - Min times a music track must repeat consecutively (1 = no mandatory repetition).
|
|
76
77
|
* @property {number} voiceMaxConsecutive - Max times a voice track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
|
|
78
|
+
* @property {number} voiceMinConsecutive - Min times a voice track must repeat consecutively (1 = no mandatory repetition).
|
|
77
79
|
*/
|
|
78
80
|
//////////////////////////////////////////////////////////////////
|
|
79
81
|
/**
|
|
@@ -85,6 +87,10 @@ const checkDestroy = createCheckDestroyed('TinyRadioFm');
|
|
|
85
87
|
* @typedef {Object} CycleBlock
|
|
86
88
|
* @property {CycleBlockData[]} items - Items belonging to this cycle.
|
|
87
89
|
* @property {number} duration - Total duration of the cycle block in ms.
|
|
90
|
+
* @property {string|null} lastMusicId - ID of the last music played in this block.
|
|
91
|
+
* @property {number} musicConsecutiveCount - Current consecutive count for music.
|
|
92
|
+
* @property {string|null} lastVoiceId - ID of the last voice played in this block.
|
|
93
|
+
* @property {number} voiceConsecutiveCount - Current consecutive count for voice.
|
|
88
94
|
*/
|
|
89
95
|
/**
|
|
90
96
|
* Information about the location of a specific cycle within the absolute timeline.
|
|
@@ -116,8 +122,8 @@ class TinyRadioFm extends EventEmitter {
|
|
|
116
122
|
* extracting metadata from an audio source.
|
|
117
123
|
*
|
|
118
124
|
* @param {string | HTMLMediaElement} source - A URL string or an existing Audio object.
|
|
119
|
-
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata
|
|
120
|
-
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata
|
|
125
|
+
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata.
|
|
126
|
+
* @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata.
|
|
121
127
|
* @param {ParseMediaContentMetadata} [parseFile] - Private helper to interface with parseFile.
|
|
122
128
|
* @param {Object} [callbacks={}] - Callbacks for monitoring the loading process.
|
|
123
129
|
* @param {(progress: LoadingMediaProgress) => void} [callbacks.onProgress] - Callback triggered on stage changes.
|
|
@@ -295,7 +301,9 @@ class TinyRadioFm extends EventEmitter {
|
|
|
295
301
|
voiceMin: 0,
|
|
296
302
|
voiceMax: 1,
|
|
297
303
|
musicMaxConsecutive: 0,
|
|
304
|
+
musicMinConsecutive: 0,
|
|
298
305
|
voiceMaxConsecutive: 0,
|
|
306
|
+
voiceMinConsecutive: 0,
|
|
299
307
|
};
|
|
300
308
|
/**
|
|
301
309
|
* Gets a deep clone of the current radio configuration.
|
|
@@ -330,7 +338,7 @@ class TinyRadioFm extends EventEmitter {
|
|
|
330
338
|
throw new TypeError('Configuration must be a valid object.');
|
|
331
339
|
}
|
|
332
340
|
const modes = ['playlist', 'random'];
|
|
333
|
-
//
|
|
341
|
+
// Type and Bounds Validation
|
|
334
342
|
if (config.mode !== undefined && !modes.includes(config.mode)) {
|
|
335
343
|
throw new TypeError(`Invalid mode: "${config.mode}". Must be one of: ${modes.join(', ')}.`);
|
|
336
344
|
}
|
|
@@ -348,29 +356,73 @@ class TinyRadioFm extends EventEmitter {
|
|
|
348
356
|
if (config.voiceAfterMusic !== undefined && typeof config.voiceAfterMusic !== 'boolean') {
|
|
349
357
|
throw new TypeError('voiceAfterMusic must be a boolean.');
|
|
350
358
|
}
|
|
351
|
-
|
|
352
|
-
(typeof
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
359
|
+
const checkRange = (/** @type {number | undefined} */ val, /** @type {string} */ minKey) => {
|
|
360
|
+
if (val !== undefined && (typeof val !== 'number' || val < 0)) {
|
|
361
|
+
throw new TypeError(`${minKey} must be a non-negative number.`);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
checkRange(config.voiceMin, 'voiceMin');
|
|
365
|
+
checkRange(config.voiceMax, 'voiceMax');
|
|
366
|
+
checkRange(config.musicMinConsecutive, 'musicMinConsecutive');
|
|
367
|
+
checkRange(config.musicMaxConsecutive, 'musicMaxConsecutive');
|
|
368
|
+
checkRange(config.voiceMinConsecutive, 'voiceMinConsecutive');
|
|
369
|
+
checkRange(config.voiceMaxConsecutive, 'voiceMaxConsecutive');
|
|
370
|
+
// Logical cross-field validation
|
|
371
|
+
const vMin = config.voiceMin ?? this.#config.voiceMin;
|
|
372
|
+
const vMax = config.voiceMax ?? this.#config.voiceMax;
|
|
373
|
+
if (vMax < vMin) {
|
|
374
|
+
throw new RangeError(`Logical error: voiceMax (${vMax}) cannot be less than voiceMin (${vMin}).`);
|
|
366
375
|
}
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
throw new RangeError(`Logical error: voiceMax (${max}) cannot be less than voiceMin (${min}).`);
|
|
376
|
+
const mMin = config.musicMinConsecutive ?? this.#config.musicMinConsecutive;
|
|
377
|
+
const mMax = config.musicMaxConsecutive ?? this.#config.musicMaxConsecutive;
|
|
378
|
+
if (mMax !== -1 && mMin > mMax) {
|
|
379
|
+
throw new RangeError(`Logical error: musicMinConsecutive (${mMin}) cannot be greater than musicMaxConsecutive (${mMax}).`);
|
|
372
380
|
}
|
|
373
381
|
}
|
|
382
|
+
/** @type {boolean} */
|
|
383
|
+
#optimizeMediaMemory = true;
|
|
384
|
+
/**
|
|
385
|
+
* Gets whether Blob URLs for media content
|
|
386
|
+
* should be converted to Base64 during export and restored during import.
|
|
387
|
+
* @returns {boolean}
|
|
388
|
+
*/
|
|
389
|
+
get optimizeMediaMemory() {
|
|
390
|
+
checkDestroy(this.#destroyed);
|
|
391
|
+
return this.#optimizeMediaMemory;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Sets whether Blob URLs should be converted to Base64 during export and restored during import.
|
|
395
|
+
* @param {boolean} value
|
|
396
|
+
* @throws {TypeError} If value is not a boolean.
|
|
397
|
+
*/
|
|
398
|
+
set optimizeMediaMemory(value) {
|
|
399
|
+
checkDestroy(this.#destroyed);
|
|
400
|
+
if (typeof value !== 'boolean')
|
|
401
|
+
throw new TypeError('optimizeMediaMemory must be a boolean.');
|
|
402
|
+
this.#optimizeMediaMemory = value;
|
|
403
|
+
}
|
|
404
|
+
/** @type {boolean} */
|
|
405
|
+
#optimizePictureMemory = true;
|
|
406
|
+
/**
|
|
407
|
+
* Gets whether Blob URLs for pictures
|
|
408
|
+
* should be converted to Base64 during export and restored during import.
|
|
409
|
+
* @returns {boolean}
|
|
410
|
+
*/
|
|
411
|
+
get optimizePictureMemory() {
|
|
412
|
+
checkDestroy(this.#destroyed);
|
|
413
|
+
return this.#optimizePictureMemory;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Sets whether Blob URLs should be converted to Base64 during export and restored during import.
|
|
417
|
+
* @param {boolean} value
|
|
418
|
+
* @throws {TypeError} If value is not a boolean.
|
|
419
|
+
*/
|
|
420
|
+
set optimizePictureMemory(value) {
|
|
421
|
+
checkDestroy(this.#destroyed);
|
|
422
|
+
if (typeof value !== 'boolean')
|
|
423
|
+
throw new TypeError('optimizePictureMemory must be a boolean.');
|
|
424
|
+
this.#optimizePictureMemory = value;
|
|
425
|
+
}
|
|
374
426
|
/**
|
|
375
427
|
* Initializes the radio system.
|
|
376
428
|
* @param {TinyRadioFmImport|null} [initialData=null] - JSON object to hydrate the radio state.
|
|
@@ -393,6 +445,58 @@ class TinyRadioFm extends EventEmitter {
|
|
|
393
445
|
}
|
|
394
446
|
}
|
|
395
447
|
// --- PUBLIC API ---
|
|
448
|
+
/**
|
|
449
|
+
* Converts new content base64 instantly to the blob url.
|
|
450
|
+
* @param {MediaContent} data
|
|
451
|
+
*/
|
|
452
|
+
_ccBase64ToBlobUrl(data) {
|
|
453
|
+
if (this.#optimizeMediaMemory) {
|
|
454
|
+
// Restores the content url if it is a Base64 string
|
|
455
|
+
if (typeof data.url === 'string' && data.url.startsWith('data:')) {
|
|
456
|
+
data.url = convertToBlobUrl(data.url);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (data.picture && Array.isArray(data.picture)) {
|
|
460
|
+
data.picture = data.picture.map((pic) => {
|
|
461
|
+
if (this.#optimizePictureMemory) {
|
|
462
|
+
// Check whether the data is compatible with the Blob structure (Base64 or Binary)
|
|
463
|
+
const isBase64 = typeof pic.data === 'string' && pic.data.startsWith('data:');
|
|
464
|
+
const isUint8Array = pic.data instanceof Uint8Array;
|
|
465
|
+
if (isBase64 || isUint8Array) {
|
|
466
|
+
return {
|
|
467
|
+
...pic,
|
|
468
|
+
data: convertToBlobUrl(pic.data, pic.format),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
// If memory optimization is disabled or it is a default URL, return as is
|
|
473
|
+
return pic;
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Restores the content blob instantly to the base64 url.
|
|
479
|
+
* @param {MediaContent} data
|
|
480
|
+
*/
|
|
481
|
+
async _rcblobUrlToBase64(data) {
|
|
482
|
+
// Optimizes the content url (audio/music) if it is a Blob URL
|
|
483
|
+
if (this.#optimizeMediaMemory && typeof data.url === 'string' && data.url.startsWith('blob:')) {
|
|
484
|
+
data.url = await blobUrlToBase64(data.url);
|
|
485
|
+
}
|
|
486
|
+
// Optimizes the pictures
|
|
487
|
+
if (this.#optimizePictureMemory && data.picture && Array.isArray(data.picture)) {
|
|
488
|
+
data.picture = await Promise.all(data.picture.map(async (pic) => {
|
|
489
|
+
// Only performs the asynchronous conversion if it really is a Blob URL
|
|
490
|
+
if (typeof pic.data === 'string' && pic.data.startsWith('blob:')) {
|
|
491
|
+
return {
|
|
492
|
+
...pic,
|
|
493
|
+
data: await blobUrlToBase64(pic.data),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return pic; // Keep intact if it is a standard URL
|
|
497
|
+
}));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
396
500
|
/**
|
|
397
501
|
* Adds new content instantly to the radio sequence.
|
|
398
502
|
* @param {'music'|'voice'|'custom'} type - The category of the content.
|
|
@@ -411,19 +515,21 @@ class TinyRadioFm extends EventEmitter {
|
|
|
411
515
|
if (typeof smartQueue !== 'boolean') {
|
|
412
516
|
throw new TypeError('smartQueue must be a boolean.');
|
|
413
517
|
}
|
|
518
|
+
const newItem = structuredClone(data);
|
|
519
|
+
this._ccBase64ToBlobUrl(newItem);
|
|
414
520
|
if (type === 'music') {
|
|
415
|
-
this.#musicList.push(
|
|
521
|
+
this.#musicList.push(newItem);
|
|
416
522
|
this.#cycleCache.clear();
|
|
417
523
|
}
|
|
418
524
|
else if (type === 'voice') {
|
|
419
|
-
this.#voiceList.push(
|
|
525
|
+
this.#voiceList.push(newItem);
|
|
420
526
|
this.#cycleCache.clear();
|
|
421
527
|
}
|
|
422
528
|
else if (type === 'custom') {
|
|
423
|
-
this.#handleCustomInsertion(
|
|
529
|
+
this.#handleCustomInsertion(newItem, smartQueue);
|
|
424
530
|
}
|
|
425
531
|
this.#syncRealTimeState(Date.now());
|
|
426
|
-
this.emit('contentAdded', { type, data: structuredClone(
|
|
532
|
+
this.emit('contentAdded', { type, data: structuredClone(newItem) });
|
|
427
533
|
}
|
|
428
534
|
/**
|
|
429
535
|
* Schedules a modification to the base playlists, seamlessly breaking the timeline when activated.
|
|
@@ -455,9 +561,8 @@ class TinyRadioFm extends EventEmitter {
|
|
|
455
561
|
}
|
|
456
562
|
}
|
|
457
563
|
else if (action === 'remove') {
|
|
458
|
-
if (typeof payload !== 'string')
|
|
564
|
+
if (typeof payload !== 'string')
|
|
459
565
|
throw new TypeError('Payload for "remove" must be a string (the content ID).');
|
|
460
|
-
}
|
|
461
566
|
}
|
|
462
567
|
else if (action === 'move') {
|
|
463
568
|
if (typeof payload !== 'object' ||
|
|
@@ -475,9 +580,8 @@ class TinyRadioFm extends EventEmitter {
|
|
|
475
580
|
const eventAtTime = this.#getEventAtTime(timestamp, timestamp);
|
|
476
581
|
// If there is, we set the timing of the task to the exact fraction of milliseconds
|
|
477
582
|
// in which this event ends before the next audio begins.
|
|
478
|
-
if (eventAtTime)
|
|
583
|
+
if (eventAtTime)
|
|
479
584
|
finalTimestamp = eventAtTime.absoluteEnd;
|
|
480
|
-
}
|
|
481
585
|
}
|
|
482
586
|
/** @type {ScheduledTask} */
|
|
483
587
|
const task = { timestamp: finalTimestamp, action, type, payload };
|
|
@@ -564,12 +668,10 @@ class TinyRadioFm extends EventEmitter {
|
|
|
564
668
|
*/
|
|
565
669
|
queryTimeline(targetDate, limit = 10) {
|
|
566
670
|
checkDestroy(this.#destroyed);
|
|
567
|
-
if (typeof limit !== 'number' || isNaN(limit))
|
|
568
|
-
throw new TypeError(
|
|
569
|
-
|
|
570
|
-
if (limit > this.#config.queryLimit || limit <= 0) {
|
|
671
|
+
if (typeof limit !== 'number' || isNaN(limit))
|
|
672
|
+
throw new TypeError('Invalid query limit value. Ensure it is a number.');
|
|
673
|
+
if (limit > this.#config.queryLimit || limit <= 0)
|
|
571
674
|
throw new RangeError(`Invalid query limit. Ensure it is > 0 and <= ${this.#config.queryLimit}.`);
|
|
572
|
-
}
|
|
573
675
|
/**
|
|
574
676
|
* Virtual instance to sandbox the timeline prediction.
|
|
575
677
|
* @type {TinyRadioFm}
|
|
@@ -600,7 +702,7 @@ class TinyRadioFm extends EventEmitter {
|
|
|
600
702
|
return [...this.#customPositions];
|
|
601
703
|
}
|
|
602
704
|
/**
|
|
603
|
-
* Process a content list, waiting to convert the images from Blob URL to Base64.
|
|
705
|
+
* Process a content list, waiting to convert the images and content URLs from Blob URL to Base64.
|
|
604
706
|
* @param {MediaContent[]} list
|
|
605
707
|
* @returns {Promise<MediaContent[]>}
|
|
606
708
|
* @private
|
|
@@ -608,19 +710,7 @@ class TinyRadioFm extends EventEmitter {
|
|
|
608
710
|
async _processListForExport(list) {
|
|
609
711
|
return Promise.all(list.map(async (item) => {
|
|
610
712
|
const newItem = structuredClone(item);
|
|
611
|
-
|
|
612
|
-
newItem.picture = await Promise.all(newItem.picture.map(async (pic) => {
|
|
613
|
-
// Only performs the asynchronous conversion if it really is a Blob URL
|
|
614
|
-
if (typeof pic.data === 'string' && pic.data.startsWith('blob:')) {
|
|
615
|
-
return {
|
|
616
|
-
...pic,
|
|
617
|
-
data: await blobUrlToBase64(pic.data),
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
|
-
// If it is a normal URL, skip the process and keep it as it is
|
|
621
|
-
return pic;
|
|
622
|
-
}));
|
|
623
|
-
}
|
|
713
|
+
await this._rcblobUrlToBase64(newItem);
|
|
624
714
|
return newItem;
|
|
625
715
|
}));
|
|
626
716
|
}
|
|
@@ -720,14 +810,25 @@ class TinyRadioFm extends EventEmitter {
|
|
|
720
810
|
* @param {MediaContent[]} list - The source list to sequence.
|
|
721
811
|
* @param {number} currentSeed - Cycle-specific seed.
|
|
722
812
|
* @param {RadioModes} mode - Processing mode.
|
|
723
|
-
* @param {number} [
|
|
724
|
-
* @
|
|
813
|
+
* @param {number} [minConsecutive=0] - Min times a track must repeat.
|
|
814
|
+
* @param {number} [maxConsecutive=0] - Max times a track can repeat.
|
|
815
|
+
* @param {string|null} [initialLastId=null] - Memory of the last played ID.
|
|
816
|
+
* @param {number} [initialConsecutiveCount=0] - Memory of the consecutive count.
|
|
817
|
+
* @returns {{ items: MediaContent[], lastId: string|null, consecutiveCount: number }}
|
|
725
818
|
*/
|
|
726
|
-
#buildSequence(list, currentSeed, mode, maxConsecutive = 0) {
|
|
727
|
-
if (list.length === 0)
|
|
728
|
-
return [];
|
|
729
|
-
|
|
730
|
-
|
|
819
|
+
#buildSequence(list, currentSeed, mode, minConsecutive = 0, maxConsecutive = 0, initialLastId = null, initialConsecutiveCount = 0) {
|
|
820
|
+
if (list.length === 0) {
|
|
821
|
+
return { items: [], lastId: initialLastId, consecutiveCount: initialConsecutiveCount };
|
|
822
|
+
}
|
|
823
|
+
if (mode !== 'random') {
|
|
824
|
+
const items = [...list];
|
|
825
|
+
const lastItem = items[items.length - 1];
|
|
826
|
+
return {
|
|
827
|
+
items,
|
|
828
|
+
lastId: lastItem.id,
|
|
829
|
+
consecutiveCount: initialLastId === lastItem.id ? initialConsecutiveCount + 1 : 1,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
731
832
|
/**
|
|
732
833
|
* Pool of available items for weighted selection.
|
|
733
834
|
* @type {Array<MediaContent & { weight: number }>}
|
|
@@ -740,49 +841,59 @@ class TinyRadioFm extends EventEmitter {
|
|
|
740
841
|
*/
|
|
741
842
|
const sequence = [];
|
|
742
843
|
/** @type {string|null} */
|
|
743
|
-
let lastId =
|
|
744
|
-
let consecutiveCount =
|
|
844
|
+
let lastId = initialLastId;
|
|
845
|
+
let consecutiveCount = initialConsecutiveCount;
|
|
745
846
|
while (pool.length > 0) {
|
|
746
|
-
let validPool = pool;
|
|
747
|
-
// If there is a restriction rule and the consecutive limit has been reached
|
|
748
|
-
if (maxConsecutive !== -1 && lastId !== null && consecutiveCount > maxConsecutive) {
|
|
749
|
-
// Try filtering the last played to force the rotation
|
|
750
|
-
validPool = pool.filter((item) => item.id !== lastId);
|
|
751
|
-
// Self-adjustment: If there is nothing left in the urn (small/no diversity playlist),
|
|
752
|
-
// we revert to the full pool so as not to stagnate the cycle.
|
|
753
|
-
if (validPool.length === 0) {
|
|
754
|
-
validPool = pool;
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
const totalWeight = validPool.reduce((sum, item) => sum + item.weight, 0);
|
|
758
|
-
// Safety catch if all weights are 0
|
|
759
|
-
if (totalWeight <= 0) {
|
|
760
|
-
sequence.push(...validPool);
|
|
761
|
-
break;
|
|
762
|
-
}
|
|
763
|
-
const r = random() * totalWeight;
|
|
764
|
-
let sum = 0;
|
|
765
847
|
/** @type {MediaContent|null} */
|
|
766
848
|
let selectedItem = null;
|
|
767
849
|
let selectedIndex = -1;
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
if (
|
|
772
|
-
selectedItem =
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
850
|
+
// 1. Minimum repetition logic
|
|
851
|
+
if (lastId !== null && consecutiveCount < minConsecutive) {
|
|
852
|
+
selectedIndex = pool.findIndex((item) => item.id === lastId);
|
|
853
|
+
if (selectedIndex !== -1) {
|
|
854
|
+
selectedItem = pool[selectedIndex];
|
|
855
|
+
}
|
|
856
|
+
else if (sequence.length > 0) {
|
|
857
|
+
// Duplicates the previous item without consuming from pool
|
|
858
|
+
selectedItem = sequence[sequence.length - 1];
|
|
776
859
|
}
|
|
777
860
|
}
|
|
778
|
-
//
|
|
779
|
-
if (!selectedItem
|
|
780
|
-
|
|
781
|
-
|
|
861
|
+
// 2. Maximum repetition logic and random draw
|
|
862
|
+
if (!selectedItem) {
|
|
863
|
+
let validPool = pool;
|
|
864
|
+
if (lastId !== null && maxConsecutive !== -1 && consecutiveCount >= maxConsecutive) {
|
|
865
|
+
validPool = pool.filter((item) => item.id !== lastId);
|
|
866
|
+
}
|
|
867
|
+
if (validPool.length === 0) {
|
|
868
|
+
validPool = pool;
|
|
869
|
+
}
|
|
870
|
+
const totalWeight = validPool.reduce((sum, item) => sum + item.weight, 0);
|
|
871
|
+
if (totalWeight <= 0) {
|
|
872
|
+
selectedItem = validPool[0];
|
|
873
|
+
selectedIndex = pool.findIndex((p) => p.id === selectedItem?.id);
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
const r = random() * totalWeight;
|
|
877
|
+
let sum = 0;
|
|
878
|
+
for (let i = 0; i < validPool.length; i++) {
|
|
879
|
+
sum += validPool[i].weight;
|
|
880
|
+
if (r <= sum) {
|
|
881
|
+
selectedItem = validPool[i];
|
|
882
|
+
selectedIndex = pool.findIndex((p) => p.id === selectedItem?.id);
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// Safety fallback
|
|
889
|
+
if (!selectedItem) {
|
|
890
|
+
selectedItem = pool[0];
|
|
891
|
+
selectedIndex = 0;
|
|
782
892
|
}
|
|
783
893
|
sequence.push(selectedItem);
|
|
784
|
-
|
|
785
|
-
|
|
894
|
+
if (selectedIndex !== -1) {
|
|
895
|
+
pool.splice(selectedIndex, 1);
|
|
896
|
+
}
|
|
786
897
|
if (selectedItem.id === lastId) {
|
|
787
898
|
consecutiveCount++;
|
|
788
899
|
}
|
|
@@ -791,7 +902,7 @@ class TinyRadioFm extends EventEmitter {
|
|
|
791
902
|
consecutiveCount = 1;
|
|
792
903
|
}
|
|
793
904
|
}
|
|
794
|
-
return sequence;
|
|
905
|
+
return { items: sequence, lastId, consecutiveCount };
|
|
795
906
|
}
|
|
796
907
|
/**
|
|
797
908
|
* Generates a structural block representing a single full cycle of the radio.
|
|
@@ -801,8 +912,25 @@ class TinyRadioFm extends EventEmitter {
|
|
|
801
912
|
#buildCycleBlock(loopIndex) {
|
|
802
913
|
const cycleSeed = this.#seed + loopIndex;
|
|
803
914
|
const mixRandom = this._prng(cycleSeed * 10);
|
|
804
|
-
|
|
805
|
-
|
|
915
|
+
// Initial memory state
|
|
916
|
+
let initialLastMusicId = null;
|
|
917
|
+
let initialMusicConsecutive = 0;
|
|
918
|
+
let initialLastVoiceId = null;
|
|
919
|
+
let initialVoiceConsecutive = 0;
|
|
920
|
+
// Rescues memory from the previous block seamlessly
|
|
921
|
+
if (loopIndex > 0) {
|
|
922
|
+
const prevBlock = this.#cycleCache.get(loopIndex - 1);
|
|
923
|
+
if (prevBlock) {
|
|
924
|
+
initialLastMusicId = prevBlock.lastMusicId ?? null;
|
|
925
|
+
initialMusicConsecutive = prevBlock.musicConsecutiveCount ?? 0;
|
|
926
|
+
initialLastVoiceId = prevBlock.lastVoiceId ?? null;
|
|
927
|
+
initialVoiceConsecutive = prevBlock.voiceConsecutiveCount ?? 0;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const musicData = this.#buildSequence(this.#musicList, cycleSeed + 1, this.#config.mode, this.#config.musicMinConsecutive, this.#config.musicMaxConsecutive, initialLastMusicId, initialMusicConsecutive);
|
|
931
|
+
const musicSeq = musicData.items;
|
|
932
|
+
const voiceData = this.#buildSequence(this.#voiceList, cycleSeed + 2, this.#config.voiceMode, this.#config.voiceMinConsecutive, this.#config.voiceMaxConsecutive, initialLastVoiceId, initialVoiceConsecutive);
|
|
933
|
+
const voiceSeq = voiceData.items;
|
|
806
934
|
/**
|
|
807
935
|
* Array containing the positioned items for the current cycle.
|
|
808
936
|
* @type {CycleBlockData[]}
|
|
@@ -833,7 +961,14 @@ class TinyRadioFm extends EventEmitter {
|
|
|
833
961
|
}
|
|
834
962
|
}
|
|
835
963
|
}
|
|
836
|
-
return {
|
|
964
|
+
return {
|
|
965
|
+
items: block,
|
|
966
|
+
duration: cycleDuration,
|
|
967
|
+
lastMusicId: musicData.lastId,
|
|
968
|
+
musicConsecutiveCount: musicData.consecutiveCount,
|
|
969
|
+
lastVoiceId: voiceData.lastId,
|
|
970
|
+
voiceConsecutiveCount: voiceData.consecutiveCount,
|
|
971
|
+
};
|
|
837
972
|
}
|
|
838
973
|
/**
|
|
839
974
|
* Fast-forwards to find the exact cycle encompassing the target absolute timestamp.
|
|
@@ -989,9 +1124,8 @@ class TinyRadioFm extends EventEmitter {
|
|
|
989
1124
|
const eventAtTime = this.#getEventAtTime(originalTarget, originalTarget);
|
|
990
1125
|
// If there is any content playing (whether music, voice or even other custom),
|
|
991
1126
|
// we push the initial target to the exact millisecond where it ends.
|
|
992
|
-
if (eventAtTime)
|
|
1127
|
+
if (eventAtTime)
|
|
993
1128
|
originalTarget = eventAtTime.absoluteEnd;
|
|
994
|
-
}
|
|
995
1129
|
}
|
|
996
1130
|
const activeCps = [...this.#customPositions].sort((a, b) => a.intendedTimestamp - b.intendedTimestamp);
|
|
997
1131
|
let bestSlot = originalTarget;
|
|
@@ -1069,8 +1203,7 @@ class TinyRadioFm extends EventEmitter {
|
|
|
1069
1203
|
list.push(/** @type {MediaContent} */ (task.payload));
|
|
1070
1204
|
}
|
|
1071
1205
|
else if (task.action === 'remove') {
|
|
1072
|
-
const
|
|
1073
|
-
const idx = list.findIndex((i) => i.id === payloadId);
|
|
1206
|
+
const idx = list.findIndex((i) => i.id === task.payload);
|
|
1074
1207
|
if (idx !== -1) {
|
|
1075
1208
|
const [removedItem] = list.splice(idx, 1);
|
|
1076
1209
|
revokeContentUrls(removedItem); // Free memory!
|
|
@@ -1090,9 +1223,8 @@ class TinyRadioFm extends EventEmitter {
|
|
|
1090
1223
|
this.emit('taskExecuted', structuredClone(task));
|
|
1091
1224
|
});
|
|
1092
1225
|
}
|
|
1093
|
-
if (listsMutated)
|
|
1226
|
+
if (listsMutated)
|
|
1094
1227
|
this.#cycleCache.clear();
|
|
1095
|
-
}
|
|
1096
1228
|
}
|
|
1097
1229
|
/**
|
|
1098
1230
|
* Hydrates class state from an exported JSON object.
|
|
@@ -1111,27 +1243,11 @@ class TinyRadioFm extends EventEmitter {
|
|
|
1111
1243
|
revokeContentUrls(/** @type {MediaContent} */ (task.payload));
|
|
1112
1244
|
}
|
|
1113
1245
|
});
|
|
1114
|
-
const processListForImport = (/** @type {MediaContent[]} */ list) => {
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
// Check whether the data is compatible with the Blob structure (Base64 or Binary)
|
|
1120
|
-
const isBase64 = typeof pic.data === 'string' && pic.data.startsWith('data:');
|
|
1121
|
-
const isUint8Array = pic.data instanceof Uint8Array;
|
|
1122
|
-
if (isBase64 || isUint8Array) {
|
|
1123
|
-
return {
|
|
1124
|
-
...pic,
|
|
1125
|
-
data: convertToBlobUrl(pic.data, pic.format),
|
|
1126
|
-
};
|
|
1127
|
-
}
|
|
1128
|
-
// If it is a default web URL or local file, use the URL directly
|
|
1129
|
-
return pic;
|
|
1130
|
-
});
|
|
1131
|
-
}
|
|
1132
|
-
return newItem;
|
|
1133
|
-
});
|
|
1134
|
-
};
|
|
1246
|
+
const processListForImport = (/** @type {MediaContent[]} */ list) => list.map((item) => {
|
|
1247
|
+
const newItem = structuredClone(item);
|
|
1248
|
+
this._ccBase64ToBlobUrl(newItem);
|
|
1249
|
+
return newItem;
|
|
1250
|
+
});
|
|
1135
1251
|
this.#musicList = processListForImport(data.music);
|
|
1136
1252
|
this.#voiceList = processListForImport(data.voice);
|
|
1137
1253
|
this.#seed = data.seed || 0;
|