tiny-essentials 1.28.6 → 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.
Files changed (54) hide show
  1. package/changelog/1/28/6.md +3 -0
  2. package/changelog/1/28/7.md +7 -0
  3. package/changelog/1/28/8.md +17 -0
  4. package/dist/v1/TinyBasicsEs.min.js +1 -1
  5. package/dist/v1/TinyDebugger.min.js +1 -0
  6. package/dist/v1/TinyEssentials.min.js +1 -1
  7. package/dist/v1/TinyMediaPlayer.min.js +1 -1
  8. package/dist/v1/TinyRadioFm.min.js +1 -1
  9. package/dist/v1/TinyUploadClicker.min.js +1 -1
  10. package/dist/v1/basics/browserDetector.cjs +228 -0
  11. package/dist/v1/basics/browserDetector.d.mts +66 -0
  12. package/dist/v1/basics/browserDetector.mjs +207 -0
  13. package/dist/v1/basics/index.cjs +6 -0
  14. package/dist/v1/basics/index.d.mts +2 -1
  15. package/dist/v1/basics/index.mjs +2 -1
  16. package/dist/v1/build/TinyDebugger.cjs +7 -0
  17. package/dist/v1/build/TinyDebugger.d.mts +3 -0
  18. package/dist/v1/build/TinyDebugger.mjs +2 -0
  19. package/dist/v1/index.cjs +8 -0
  20. package/dist/v1/index.d.mts +3 -1
  21. package/dist/v1/index.mjs +4 -2
  22. package/dist/v1/libs/TinyDebugger.cjs +380 -0
  23. package/dist/v1/libs/TinyDebugger.d.mts +132 -0
  24. package/dist/v1/libs/TinyDebugger.mjs +351 -0
  25. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.cjs +66 -3
  26. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.d.mts +25 -0
  27. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.mjs +59 -3
  28. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.cjs +66 -3
  29. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.d.mts +25 -0
  30. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.mjs +59 -3
  31. package/dist/v1/libs/TinyMediaPlayer/Mock.cjs +333 -0
  32. package/dist/v1/libs/TinyMediaPlayer/Mock.d.mts +154 -0
  33. package/dist/v1/libs/TinyMediaPlayer/Mock.mjs +296 -0
  34. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.cjs +85 -1
  35. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.d.mts +25 -0
  36. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.mjs +75 -1
  37. package/dist/v1/libs/TinyMediaPlayer/Youtube.cjs +66 -3
  38. package/dist/v1/libs/TinyMediaPlayer/Youtube.d.mts +25 -0
  39. package/dist/v1/libs/TinyMediaPlayer/Youtube.mjs +56 -3
  40. package/dist/v1/libs/TinyMediaPlayer/index.cjs +45 -0
  41. package/dist/v1/libs/TinyMediaPlayer/index.d.mts +25 -0
  42. package/dist/v1/libs/TinyMediaPlayer/index.mjs +40 -0
  43. package/dist/v1/libs/TinyMediaPlayer.cjs +172 -10
  44. package/dist/v1/libs/TinyMediaPlayer.d.mts +47 -2
  45. package/dist/v1/libs/TinyMediaPlayer.mjs +160 -10
  46. package/dist/v1/libs/TinyPromiseQueue.cjs +6 -4
  47. package/dist/v1/libs/TinyPromiseQueue.d.mts +8 -6
  48. package/dist/v1/libs/TinyPromiseQueue.mjs +6 -4
  49. package/dist/v1/libs/TinyRadioFm.cjs +271 -138
  50. package/dist/v1/libs/TinyRadioFm.d.mts +67 -3
  51. package/dist/v1/libs/TinyRadioFm.mjs +239 -123
  52. package/docs/v1/libs/TinyMediaPlayer.md +52 -54
  53. package/docs/v1/libs/TinyRadioFm.md +25 -3
  54. package/package.json +13 -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 that overrides automatic extraction.
309
- * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata that overrides automatic extraction.
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 that overrides automatic extraction.
120
- * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata that overrides automatic extraction.
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
- // 1. Type and Bounds Validation
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
- if (config.voiceMin !== undefined &&
352
- (typeof config.voiceMin !== 'number' || config.voiceMin < 0)) {
353
- throw new TypeError('voiceMin must be a non-negative number.');
354
- }
355
- if (config.voiceMax !== undefined &&
356
- (typeof config.voiceMax !== 'number' || config.voiceMax < 0)) {
357
- throw new TypeError('voiceMax must be a non-negative number.');
358
- }
359
- if (config.musicMaxConsecutive !== undefined &&
360
- (typeof config.musicMaxConsecutive !== 'number' || config.musicMaxConsecutive < -1)) {
361
- throw new TypeError('musicMaxConsecutive must be a number >= -1.');
362
- }
363
- if (config.voiceMaxConsecutive !== undefined &&
364
- (typeof config.voiceMaxConsecutive !== 'number' || config.voiceMaxConsecutive < -1)) {
365
- throw new TypeError('voiceMaxConsecutive must be a number >= -1.');
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
- // 2. Logical Cross-Field Validation
368
- const min = config.voiceMin ?? this.#config.voiceMin;
369
- const max = config.voiceMax ?? this.#config.voiceMax;
370
- if (max < min) {
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(data);
521
+ this.#musicList.push(newItem);
416
522
  this.#cycleCache.clear();
417
523
  }
418
524
  else if (type === 'voice') {
419
- this.#voiceList.push(data);
525
+ this.#voiceList.push(newItem);
420
526
  this.#cycleCache.clear();
421
527
  }
422
528
  else if (type === 'custom') {
423
- this.#handleCustomInsertion(data, smartQueue);
529
+ this.#handleCustomInsertion(newItem, smartQueue);
424
530
  }
425
531
  this.#syncRealTimeState(Date.now());
426
- this.emit('contentAdded', { type, data: structuredClone(data) });
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(`Invalid query limit value. Ensure it is a number.`);
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
- if (newItem.picture && Array.isArray(newItem.picture)) {
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} [maxConsecutive=0] - Max consecutive repetitions permitted (-1 = unlimited, 0 = strictly no repeat).
724
- * @returns {MediaContent[]} The generated sequence.
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
- if (mode !== 'random')
730
- return [...list]; // Respects manual indexing/moving
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 = null;
744
- let consecutiveCount = 0;
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
- // Run the draw on the valid urn
769
- for (let i = 0; i < validPool.length; i++) {
770
- sum += validPool[i].weight;
771
- if (r <= sum) {
772
- selectedItem = validPool[i];
773
- // Find the index in the original pool for correct removal
774
- selectedIndex = pool.findIndex((p) => selectedItem && p.id === selectedItem.id);
775
- break;
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
- // Fallback in case of mathematical failure in floating accuracy
779
- if (!selectedItem || selectedIndex === -1) {
780
- selectedItem = validPool[0];
781
- selectedIndex = pool.findIndex((p) => selectedItem && p.id === selectedItem.id);
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
- pool.splice(selectedIndex, 1);
785
- // Update counters to the next loop step
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
- const musicSeq = this.#buildSequence(this.#musicList, cycleSeed + 1, this.#config.mode, this.#config.musicMaxConsecutive);
805
- const voiceSeq = this.#buildSequence(this.#voiceList, cycleSeed + 2, this.#config.voiceMode, this.#config.voiceMaxConsecutive);
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 { items: block, duration: cycleDuration };
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 payloadId = /** @type {string} */ (task.payload);
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
- return list.map((item) => {
1116
- const newItem = { ...item };
1117
- if (newItem.picture && Array.isArray(newItem.picture)) {
1118
- newItem.picture = newItem.picture.map((pic) => {
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;