tiny-essentials 1.28.7 → 1.28.9

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 (55) hide show
  1. package/changelog/1/28/9.md +17 -0
  2. package/dist/v1/TinyBasicsEs.min.js +1 -1
  3. package/dist/v1/TinyDebugger.min.js +1 -0
  4. package/dist/v1/TinyEssentials.min.js +1 -1
  5. package/dist/v1/TinyMediaPlayer.min.js +1 -1
  6. package/dist/v1/TinyRadioFm.min.js +1 -1
  7. package/dist/v1/TinyUploadClicker.min.js +1 -1
  8. package/dist/v1/basics/browserDetector.cjs +228 -0
  9. package/dist/v1/basics/browserDetector.d.mts +66 -0
  10. package/dist/v1/basics/browserDetector.mjs +207 -0
  11. package/dist/v1/basics/index.cjs +6 -0
  12. package/dist/v1/basics/index.d.mts +2 -1
  13. package/dist/v1/basics/index.mjs +2 -1
  14. package/dist/v1/build/TinyDebugger.cjs +7 -0
  15. package/dist/v1/build/TinyDebugger.d.mts +3 -0
  16. package/dist/v1/build/TinyDebugger.mjs +2 -0
  17. package/dist/v1/index.cjs +8 -0
  18. package/dist/v1/index.d.mts +3 -1
  19. package/dist/v1/index.mjs +4 -2
  20. package/dist/v1/libs/TinyDebugger.cjs +380 -0
  21. package/dist/v1/libs/TinyDebugger.d.mts +132 -0
  22. package/dist/v1/libs/TinyDebugger.mjs +351 -0
  23. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.cjs +39 -3
  24. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.d.mts +10 -0
  25. package/dist/v1/libs/TinyMediaPlayer/HtmlAudio.mjs +35 -3
  26. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.cjs +39 -3
  27. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.d.mts +10 -0
  28. package/dist/v1/libs/TinyMediaPlayer/HtmlVideo.mjs +35 -3
  29. package/dist/v1/libs/TinyMediaPlayer/Mock.cjs +333 -0
  30. package/dist/v1/libs/TinyMediaPlayer/Mock.d.mts +154 -0
  31. package/dist/v1/libs/TinyMediaPlayer/Mock.mjs +296 -0
  32. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.cjs +30 -0
  33. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.d.mts +10 -0
  34. package/dist/v1/libs/TinyMediaPlayer/SoundCloud.mjs +26 -0
  35. package/dist/v1/libs/TinyMediaPlayer/Youtube.cjs +39 -3
  36. package/dist/v1/libs/TinyMediaPlayer/Youtube.d.mts +10 -0
  37. package/dist/v1/libs/TinyMediaPlayer/Youtube.mjs +32 -3
  38. package/dist/v1/libs/TinyMediaPlayer/index.cjs +18 -0
  39. package/dist/v1/libs/TinyMediaPlayer/index.d.mts +10 -0
  40. package/dist/v1/libs/TinyMediaPlayer/index.mjs +16 -0
  41. package/dist/v1/libs/TinyMediaPlayer.cjs +156 -31
  42. package/dist/v1/libs/TinyMediaPlayer.d.mts +43 -7
  43. package/dist/v1/libs/TinyMediaPlayer.mjs +148 -31
  44. package/dist/v1/libs/TinyPromiseQueue.cjs +6 -4
  45. package/dist/v1/libs/TinyPromiseQueue.d.mts +8 -6
  46. package/dist/v1/libs/TinyPromiseQueue.mjs +6 -4
  47. package/dist/v1/libs/TinyRadioFm.cjs +271 -138
  48. package/dist/v1/libs/TinyRadioFm.d.mts +67 -3
  49. package/dist/v1/libs/TinyRadioFm.mjs +239 -123
  50. package/docs/v1/README.md +2 -0
  51. package/docs/v1/basics/browserDetector.md +135 -0
  52. package/docs/v1/libs/TinyDebugger.md +119 -0
  53. package/docs/v1/libs/TinyMediaPlayer.md +26 -22
  54. package/docs/v1/libs/TinyRadioFm.md +25 -3
  55. package/package.json +13 -1
@@ -87,7 +87,9 @@ const checkDestroy = utils.createCheckDestroyed('TinyRadioFm');
87
87
  * @property {number} voiceMin - Minimum amount of voice messages to play if voiceAfterMusic is true.
88
88
  * @property {number} voiceMax - Maximum amount of voice messages to play.
89
89
  * @property {number} musicMaxConsecutive - Max times a music track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
90
+ * @property {number} musicMinConsecutive - Min times a music track must repeat consecutively (1 = no mandatory repetition).
90
91
  * @property {number} voiceMaxConsecutive - Max times a voice track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
92
+ * @property {number} voiceMinConsecutive - Min times a voice track must repeat consecutively (1 = no mandatory repetition).
91
93
  */
92
94
 
93
95
  //////////////////////////////////////////////////////////////////
@@ -102,6 +104,10 @@ const checkDestroy = utils.createCheckDestroyed('TinyRadioFm');
102
104
  * @typedef {Object} CycleBlock
103
105
  * @property {CycleBlockData[]} items - Items belonging to this cycle.
104
106
  * @property {number} duration - Total duration of the cycle block in ms.
107
+ * @property {string|null} lastMusicId - ID of the last music played in this block.
108
+ * @property {number} musicConsecutiveCount - Current consecutive count for music.
109
+ * @property {string|null} lastVoiceId - ID of the last voice played in this block.
110
+ * @property {number} voiceConsecutiveCount - Current consecutive count for voice.
105
111
  */
106
112
 
107
113
  /**
@@ -138,8 +144,8 @@ class TinyRadioFm extends events.EventEmitter {
138
144
  * extracting metadata from an audio source.
139
145
  *
140
146
  * @param {string | HTMLMediaElement} source - A URL string or an existing Audio object.
141
- * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata that overrides automatic extraction.
142
- * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata that overrides automatic extraction.
147
+ * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata.
148
+ * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata.
143
149
  * @param {ParseMediaContentMetadata} [parseFile] - Private helper to interface with parseFile.
144
150
  * @param {Object} [callbacks={}] - Callbacks for monitoring the loading process.
145
151
  * @param {(progress: LoadingMediaProgress) => void} [callbacks.onProgress] - Callback triggered on stage changes.
@@ -343,7 +349,9 @@ class TinyRadioFm extends events.EventEmitter {
343
349
  voiceMin: 0,
344
350
  voiceMax: 1,
345
351
  musicMaxConsecutive: 0,
352
+ musicMinConsecutive: 0,
346
353
  voiceMaxConsecutive: 0,
354
+ voiceMinConsecutive: 0,
347
355
  };
348
356
  /**
349
357
  * Gets a deep clone of the current radio configuration.
@@ -382,7 +390,7 @@ class TinyRadioFm extends events.EventEmitter {
382
390
 
383
391
  const modes = ['playlist', 'random'];
384
392
 
385
- // 1. Type and Bounds Validation
393
+ // Type and Bounds Validation
386
394
  if (config.mode !== undefined && !modes.includes(config.mode)) {
387
395
  throw new TypeError(`Invalid mode: "${config.mode}". Must be one of: ${modes.join(', ')}.`);
388
396
  }
@@ -411,43 +419,83 @@ class TinyRadioFm extends events.EventEmitter {
411
419
  throw new TypeError('voiceAfterMusic must be a boolean.');
412
420
  }
413
421
 
414
- if (
415
- config.voiceMin !== undefined &&
416
- (typeof config.voiceMin !== 'number' || config.voiceMin < 0)
417
- ) {
418
- throw new TypeError('voiceMin must be a non-negative number.');
419
- }
422
+ const checkRange = (/** @type {number | undefined} */ val, /** @type {string} */ minKey) => {
423
+ if (val !== undefined && (typeof val !== 'number' || val < 0)) {
424
+ throw new TypeError(`${minKey} must be a non-negative number.`);
425
+ }
426
+ };
420
427
 
421
- if (
422
- config.voiceMax !== undefined &&
423
- (typeof config.voiceMax !== 'number' || config.voiceMax < 0)
424
- ) {
425
- throw new TypeError('voiceMax must be a non-negative number.');
428
+ checkRange(config.voiceMin, 'voiceMin');
429
+ checkRange(config.voiceMax, 'voiceMax');
430
+ checkRange(config.musicMinConsecutive, 'musicMinConsecutive');
431
+ checkRange(config.musicMaxConsecutive, 'musicMaxConsecutive');
432
+ checkRange(config.voiceMinConsecutive, 'voiceMinConsecutive');
433
+ checkRange(config.voiceMaxConsecutive, 'voiceMaxConsecutive');
434
+
435
+ // Logical cross-field validation
436
+ const vMin = config.voiceMin ?? this.#config.voiceMin;
437
+ const vMax = config.voiceMax ?? this.#config.voiceMax;
438
+ if (vMax < vMin) {
439
+ throw new RangeError(
440
+ `Logical error: voiceMax (${vMax}) cannot be less than voiceMin (${vMin}).`,
441
+ );
426
442
  }
427
443
 
428
- if (
429
- config.musicMaxConsecutive !== undefined &&
430
- (typeof config.musicMaxConsecutive !== 'number' || config.musicMaxConsecutive < -1)
431
- ) {
432
- throw new TypeError('musicMaxConsecutive must be a number >= -1.');
444
+ const mMin = config.musicMinConsecutive ?? this.#config.musicMinConsecutive;
445
+ const mMax = config.musicMaxConsecutive ?? this.#config.musicMaxConsecutive;
446
+ if (mMax !== -1 && mMin > mMax) {
447
+ throw new RangeError(
448
+ `Logical error: musicMinConsecutive (${mMin}) cannot be greater than musicMaxConsecutive (${mMax}).`,
449
+ );
433
450
  }
451
+ }
434
452
 
435
- if (
436
- config.voiceMaxConsecutive !== undefined &&
437
- (typeof config.voiceMaxConsecutive !== 'number' || config.voiceMaxConsecutive < -1)
438
- ) {
439
- throw new TypeError('voiceMaxConsecutive must be a number >= -1.');
440
- }
453
+ /** @type {boolean} */
454
+ #optimizeMediaMemory = true;
441
455
 
442
- // 2. Logical Cross-Field Validation
443
- const min = config.voiceMin ?? this.#config.voiceMin;
444
- const max = config.voiceMax ?? this.#config.voiceMax;
456
+ /**
457
+ * Gets whether Blob URLs for media content
458
+ * should be converted to Base64 during export and restored during import.
459
+ * @returns {boolean}
460
+ */
461
+ get optimizeMediaMemory() {
462
+ checkDestroy(this.#destroyed);
463
+ return this.#optimizeMediaMemory;
464
+ }
445
465
 
446
- if (max < min) {
447
- throw new RangeError(
448
- `Logical error: voiceMax (${max}) cannot be less than voiceMin (${min}).`,
449
- );
450
- }
466
+ /**
467
+ * Sets whether Blob URLs should be converted to Base64 during export and restored during import.
468
+ * @param {boolean} value
469
+ * @throws {TypeError} If value is not a boolean.
470
+ */
471
+ set optimizeMediaMemory(value) {
472
+ checkDestroy(this.#destroyed);
473
+ if (typeof value !== 'boolean') throw new TypeError('optimizeMediaMemory must be a boolean.');
474
+ this.#optimizeMediaMemory = value;
475
+ }
476
+
477
+ /** @type {boolean} */
478
+ #optimizePictureMemory = true;
479
+
480
+ /**
481
+ * Gets whether Blob URLs for pictures
482
+ * should be converted to Base64 during export and restored during import.
483
+ * @returns {boolean}
484
+ */
485
+ get optimizePictureMemory() {
486
+ checkDestroy(this.#destroyed);
487
+ return this.#optimizePictureMemory;
488
+ }
489
+
490
+ /**
491
+ * Sets whether Blob URLs should be converted to Base64 during export and restored during import.
492
+ * @param {boolean} value
493
+ * @throws {TypeError} If value is not a boolean.
494
+ */
495
+ set optimizePictureMemory(value) {
496
+ checkDestroy(this.#destroyed);
497
+ if (typeof value !== 'boolean') throw new TypeError('optimizePictureMemory must be a boolean.');
498
+ this.#optimizePictureMemory = value;
451
499
  }
452
500
 
453
501
  /**
@@ -474,6 +522,66 @@ class TinyRadioFm extends events.EventEmitter {
474
522
 
475
523
  // --- PUBLIC API ---
476
524
 
525
+ /**
526
+ * Converts new content base64 instantly to the blob url.
527
+ * @param {MediaContent} data
528
+ */
529
+ _ccBase64ToBlobUrl(data) {
530
+ if (this.#optimizeMediaMemory) {
531
+ // Restores the content url if it is a Base64 string
532
+ if (typeof data.url === 'string' && data.url.startsWith('data:')) {
533
+ data.url = mediaContent.convertToBlobUrl(data.url);
534
+ }
535
+ }
536
+
537
+ if (data.picture && Array.isArray(data.picture)) {
538
+ data.picture = data.picture.map((pic) => {
539
+ if (this.#optimizePictureMemory) {
540
+ // Check whether the data is compatible with the Blob structure (Base64 or Binary)
541
+ const isBase64 = typeof pic.data === 'string' && pic.data.startsWith('data:');
542
+ const isUint8Array = pic.data instanceof Uint8Array;
543
+
544
+ if (isBase64 || isUint8Array) {
545
+ return {
546
+ ...pic,
547
+ data: mediaContent.convertToBlobUrl(pic.data, pic.format),
548
+ };
549
+ }
550
+ }
551
+
552
+ // If memory optimization is disabled or it is a default URL, return as is
553
+ return pic;
554
+ });
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Restores the content blob instantly to the base64 url.
560
+ * @param {MediaContent} data
561
+ */
562
+ async _rcblobUrlToBase64(data) {
563
+ // Optimizes the content url (audio/music) if it is a Blob URL
564
+ if (this.#optimizeMediaMemory && typeof data.url === 'string' && data.url.startsWith('blob:')) {
565
+ data.url = await mediaContent.blobUrlToBase64(data.url);
566
+ }
567
+
568
+ // Optimizes the pictures
569
+ if (this.#optimizePictureMemory && data.picture && Array.isArray(data.picture)) {
570
+ data.picture = await Promise.all(
571
+ data.picture.map(async (pic) => {
572
+ // Only performs the asynchronous conversion if it really is a Blob URL
573
+ if (typeof pic.data === 'string' && pic.data.startsWith('blob:')) {
574
+ return {
575
+ ...pic,
576
+ data: await mediaContent.blobUrlToBase64(pic.data),
577
+ };
578
+ }
579
+ return pic; // Keep intact if it is a standard URL
580
+ }),
581
+ );
582
+ }
583
+ }
584
+
477
585
  /**
478
586
  * Adds new content instantly to the radio sequence.
479
587
  * @param {'music'|'voice'|'custom'} type - The category of the content.
@@ -495,18 +603,20 @@ class TinyRadioFm extends events.EventEmitter {
495
603
  throw new TypeError('smartQueue must be a boolean.');
496
604
  }
497
605
 
606
+ const newItem = structuredClone(data);
607
+ this._ccBase64ToBlobUrl(newItem);
498
608
  if (type === 'music') {
499
- this.#musicList.push(data);
609
+ this.#musicList.push(newItem);
500
610
  this.#cycleCache.clear();
501
611
  } else if (type === 'voice') {
502
- this.#voiceList.push(data);
612
+ this.#voiceList.push(newItem);
503
613
  this.#cycleCache.clear();
504
614
  } else if (type === 'custom') {
505
- this.#handleCustomInsertion(data, smartQueue);
615
+ this.#handleCustomInsertion(newItem, smartQueue);
506
616
  }
507
617
 
508
618
  this.#syncRealTimeState(Date.now());
509
- this.emit('contentAdded', { type, data: structuredClone(data) });
619
+ this.emit('contentAdded', { type, data: structuredClone(newItem) });
510
620
  }
511
621
 
512
622
  /**
@@ -541,9 +651,8 @@ class TinyRadioFm extends events.EventEmitter {
541
651
  );
542
652
  }
543
653
  } else if (action === 'remove') {
544
- if (typeof payload !== 'string') {
654
+ if (typeof payload !== 'string')
545
655
  throw new TypeError('Payload for "remove" must be a string (the content ID).');
546
- }
547
656
  } else if (action === 'move') {
548
657
  if (
549
658
  typeof payload !== 'object' ||
@@ -567,9 +676,7 @@ class TinyRadioFm extends events.EventEmitter {
567
676
 
568
677
  // If there is, we set the timing of the task to the exact fraction of milliseconds
569
678
  // in which this event ends before the next audio begins.
570
- if (eventAtTime) {
571
- finalTimestamp = eventAtTime.absoluteEnd;
572
- }
679
+ if (eventAtTime) finalTimestamp = eventAtTime.absoluteEnd;
573
680
  }
574
681
 
575
682
  /** @type {ScheduledTask} */
@@ -672,14 +779,12 @@ class TinyRadioFm extends events.EventEmitter {
672
779
  */
673
780
  queryTimeline(targetDate, limit = 10) {
674
781
  checkDestroy(this.#destroyed);
675
- if (typeof limit !== 'number' || isNaN(limit)) {
676
- throw new TypeError(`Invalid query limit value. Ensure it is a number.`);
677
- }
678
- if (limit > this.#config.queryLimit || limit <= 0) {
782
+ if (typeof limit !== 'number' || isNaN(limit))
783
+ throw new TypeError('Invalid query limit value. Ensure it is a number.');
784
+ if (limit > this.#config.queryLimit || limit <= 0)
679
785
  throw new RangeError(
680
786
  `Invalid query limit. Ensure it is > 0 and <= ${this.#config.queryLimit}.`,
681
787
  );
682
- }
683
788
 
684
789
  /**
685
790
  * Virtual instance to sandbox the timeline prediction.
@@ -716,7 +821,7 @@ class TinyRadioFm extends events.EventEmitter {
716
821
  }
717
822
 
718
823
  /**
719
- * Process a content list, waiting to convert the images from Blob URL to Base64.
824
+ * Process a content list, waiting to convert the images and content URLs from Blob URL to Base64.
720
825
  * @param {MediaContent[]} list
721
826
  * @returns {Promise<MediaContent[]>}
722
827
  * @private
@@ -725,22 +830,7 @@ class TinyRadioFm extends events.EventEmitter {
725
830
  return Promise.all(
726
831
  list.map(async (item) => {
727
832
  const newItem = structuredClone(item);
728
- if (newItem.picture && Array.isArray(newItem.picture)) {
729
- newItem.picture = await Promise.all(
730
- newItem.picture.map(async (pic) => {
731
- // Only performs the asynchronous conversion if it really is a Blob URL
732
- if (typeof pic.data === 'string' && pic.data.startsWith('blob:')) {
733
- return {
734
- ...pic,
735
- data: await mediaContent.blobUrlToBase64(pic.data),
736
- };
737
- }
738
-
739
- // If it is a normal URL, skip the process and keep it as it is
740
- return pic;
741
- }),
742
- );
743
- }
833
+ await this._rcblobUrlToBase64(newItem);
744
834
  return newItem;
745
835
  }),
746
836
  );
@@ -854,12 +944,34 @@ class TinyRadioFm extends events.EventEmitter {
854
944
  * @param {MediaContent[]} list - The source list to sequence.
855
945
  * @param {number} currentSeed - Cycle-specific seed.
856
946
  * @param {RadioModes} mode - Processing mode.
857
- * @param {number} [maxConsecutive=0] - Max consecutive repetitions permitted (-1 = unlimited, 0 = strictly no repeat).
858
- * @returns {MediaContent[]} The generated sequence.
947
+ * @param {number} [minConsecutive=0] - Min times a track must repeat.
948
+ * @param {number} [maxConsecutive=0] - Max times a track can repeat.
949
+ * @param {string|null} [initialLastId=null] - Memory of the last played ID.
950
+ * @param {number} [initialConsecutiveCount=0] - Memory of the consecutive count.
951
+ * @returns {{ items: MediaContent[], lastId: string|null, consecutiveCount: number }}
859
952
  */
860
- #buildSequence(list, currentSeed, mode, maxConsecutive = 0) {
861
- if (list.length === 0) return [];
862
- if (mode !== 'random') return [...list]; // Respects manual indexing/moving
953
+ #buildSequence(
954
+ list,
955
+ currentSeed,
956
+ mode,
957
+ minConsecutive = 0,
958
+ maxConsecutive = 0,
959
+ initialLastId = null,
960
+ initialConsecutiveCount = 0,
961
+ ) {
962
+ if (list.length === 0) {
963
+ return { items: [], lastId: initialLastId, consecutiveCount: initialConsecutiveCount };
964
+ }
965
+
966
+ if (mode !== 'random') {
967
+ const items = [...list];
968
+ const lastItem = items[items.length - 1];
969
+ return {
970
+ items,
971
+ lastId: lastItem.id,
972
+ consecutiveCount: initialLastId === lastItem.id ? initialConsecutiveCount + 1 : 1,
973
+ };
974
+ }
863
975
 
864
976
  /**
865
977
  * Pool of available items for weighted selection.
@@ -875,59 +987,69 @@ class TinyRadioFm extends events.EventEmitter {
875
987
  const sequence = [];
876
988
 
877
989
  /** @type {string|null} */
878
- let lastId = null;
879
- let consecutiveCount = 0;
990
+ let lastId = initialLastId;
991
+ let consecutiveCount = initialConsecutiveCount;
880
992
 
881
993
  while (pool.length > 0) {
882
- let validPool = pool;
994
+ /** @type {MediaContent|null} */
995
+ let selectedItem = null;
996
+ let selectedIndex = -1;
883
997
 
884
- // If there is a restriction rule and the consecutive limit has been reached
885
- if (maxConsecutive !== -1 && lastId !== null && consecutiveCount > maxConsecutive) {
886
- // Try filtering the last played to force the rotation
887
- validPool = pool.filter((item) => item.id !== lastId);
998
+ // 1. Minimum repetition logic
999
+ if (lastId !== null && consecutiveCount < minConsecutive) {
1000
+ selectedIndex = pool.findIndex((item) => item.id === lastId);
888
1001
 
889
- // Self-adjustment: If there is nothing left in the urn (small/no diversity playlist),
890
- // we revert to the full pool so as not to stagnate the cycle.
891
- if (validPool.length === 0) {
892
- validPool = pool;
1002
+ if (selectedIndex !== -1) {
1003
+ selectedItem = pool[selectedIndex];
1004
+ } else if (sequence.length > 0) {
1005
+ // Duplicates the previous item without consuming from pool
1006
+ selectedItem = sequence[sequence.length - 1];
893
1007
  }
894
1008
  }
895
1009
 
896
- const totalWeight = validPool.reduce((sum, item) => sum + item.weight, 0);
1010
+ // 2. Maximum repetition logic and random draw
1011
+ if (!selectedItem) {
1012
+ let validPool = pool;
897
1013
 
898
- // Safety catch if all weights are 0
899
- if (totalWeight <= 0) {
900
- sequence.push(...validPool);
901
- break;
902
- }
1014
+ if (lastId !== null && maxConsecutive !== -1 && consecutiveCount >= maxConsecutive) {
1015
+ validPool = pool.filter((item) => item.id !== lastId);
1016
+ }
903
1017
 
904
- const r = random() * totalWeight;
905
- let sum = 0;
906
- /** @type {MediaContent|null} */
907
- let selectedItem = null;
908
- let selectedIndex = -1;
1018
+ if (validPool.length === 0) {
1019
+ validPool = pool;
1020
+ }
909
1021
 
910
- // Run the draw on the valid urn
911
- for (let i = 0; i < validPool.length; i++) {
912
- sum += validPool[i].weight;
913
- if (r <= sum) {
914
- selectedItem = validPool[i];
915
- // Find the index in the original pool for correct removal
916
- selectedIndex = pool.findIndex((p) => selectedItem && p.id === selectedItem.id);
917
- break;
1022
+ const totalWeight = validPool.reduce((sum, item) => sum + item.weight, 0);
1023
+
1024
+ if (totalWeight <= 0) {
1025
+ selectedItem = validPool[0];
1026
+ selectedIndex = pool.findIndex((p) => p.id === selectedItem?.id);
1027
+ } else {
1028
+ const r = random() * totalWeight;
1029
+ let sum = 0;
1030
+ for (let i = 0; i < validPool.length; i++) {
1031
+ sum += validPool[i].weight;
1032
+ if (r <= sum) {
1033
+ selectedItem = validPool[i];
1034
+ selectedIndex = pool.findIndex((p) => p.id === selectedItem?.id);
1035
+ break;
1036
+ }
1037
+ }
918
1038
  }
919
1039
  }
920
1040
 
921
- // Fallback in case of mathematical failure in floating accuracy
922
- if (!selectedItem || selectedIndex === -1) {
923
- selectedItem = validPool[0];
924
- selectedIndex = pool.findIndex((p) => selectedItem && p.id === selectedItem.id);
1041
+ // Safety fallback
1042
+ if (!selectedItem) {
1043
+ selectedItem = pool[0];
1044
+ selectedIndex = 0;
925
1045
  }
926
1046
 
927
1047
  sequence.push(selectedItem);
928
- pool.splice(selectedIndex, 1);
929
1048
 
930
- // Update counters to the next loop step
1049
+ if (selectedIndex !== -1) {
1050
+ pool.splice(selectedIndex, 1);
1051
+ }
1052
+
931
1053
  if (selectedItem.id === lastId) {
932
1054
  consecutiveCount++;
933
1055
  } else {
@@ -936,7 +1058,7 @@ class TinyRadioFm extends events.EventEmitter {
936
1058
  }
937
1059
  }
938
1060
 
939
- return sequence;
1061
+ return { items: sequence, lastId, consecutiveCount };
940
1062
  }
941
1063
 
942
1064
  /**
@@ -948,18 +1070,44 @@ class TinyRadioFm extends events.EventEmitter {
948
1070
  const cycleSeed = this.#seed + loopIndex;
949
1071
  const mixRandom = this._prng(cycleSeed * 10);
950
1072
 
951
- const musicSeq = this.#buildSequence(
1073
+ // Initial memory state
1074
+ let initialLastMusicId = null;
1075
+ let initialMusicConsecutive = 0;
1076
+ let initialLastVoiceId = null;
1077
+ let initialVoiceConsecutive = 0;
1078
+
1079
+ // Rescues memory from the previous block seamlessly
1080
+ if (loopIndex > 0) {
1081
+ const prevBlock = this.#cycleCache.get(loopIndex - 1);
1082
+ if (prevBlock) {
1083
+ initialLastMusicId = prevBlock.lastMusicId ?? null;
1084
+ initialMusicConsecutive = prevBlock.musicConsecutiveCount ?? 0;
1085
+ initialLastVoiceId = prevBlock.lastVoiceId ?? null;
1086
+ initialVoiceConsecutive = prevBlock.voiceConsecutiveCount ?? 0;
1087
+ }
1088
+ }
1089
+
1090
+ const musicData = this.#buildSequence(
952
1091
  this.#musicList,
953
1092
  cycleSeed + 1,
954
1093
  this.#config.mode,
1094
+ this.#config.musicMinConsecutive,
955
1095
  this.#config.musicMaxConsecutive,
1096
+ initialLastMusicId,
1097
+ initialMusicConsecutive,
956
1098
  );
957
- const voiceSeq = this.#buildSequence(
1099
+ const musicSeq = musicData.items;
1100
+
1101
+ const voiceData = this.#buildSequence(
958
1102
  this.#voiceList,
959
1103
  cycleSeed + 2,
960
1104
  this.#config.voiceMode,
1105
+ this.#config.voiceMinConsecutive,
961
1106
  this.#config.voiceMaxConsecutive,
1107
+ initialLastVoiceId,
1108
+ initialVoiceConsecutive,
962
1109
  );
1110
+ const voiceSeq = voiceData.items;
963
1111
 
964
1112
  /**
965
1113
  * Array containing the positioned items for the current cycle.
@@ -996,7 +1144,14 @@ class TinyRadioFm extends events.EventEmitter {
996
1144
  }
997
1145
  }
998
1146
 
999
- return { items: block, duration: cycleDuration };
1147
+ return {
1148
+ items: block,
1149
+ duration: cycleDuration,
1150
+ lastMusicId: musicData.lastId,
1151
+ musicConsecutiveCount: musicData.consecutiveCount,
1152
+ lastVoiceId: voiceData.lastId,
1153
+ voiceConsecutiveCount: voiceData.consecutiveCount,
1154
+ };
1000
1155
  }
1001
1156
 
1002
1157
  /**
@@ -1193,9 +1348,7 @@ class TinyRadioFm extends events.EventEmitter {
1193
1348
 
1194
1349
  // If there is any content playing (whether music, voice or even other custom),
1195
1350
  // we push the initial target to the exact millisecond where it ends.
1196
- if (eventAtTime) {
1197
- originalTarget = eventAtTime.absoluteEnd;
1198
- }
1351
+ if (eventAtTime) originalTarget = eventAtTime.absoluteEnd;
1199
1352
  }
1200
1353
 
1201
1354
  const activeCps = [...this.#customPositions].sort(
@@ -1298,8 +1451,7 @@ class TinyRadioFm extends events.EventEmitter {
1298
1451
  if (task.action === 'add') {
1299
1452
  list.push(/** @type {MediaContent} */ (task.payload));
1300
1453
  } else if (task.action === 'remove') {
1301
- const payloadId = /** @type {string} */ (task.payload);
1302
- const idx = list.findIndex((i) => i.id === payloadId);
1454
+ const idx = list.findIndex((i) => i.id === task.payload);
1303
1455
  if (idx !== -1) {
1304
1456
  const [removedItem] = list.splice(idx, 1);
1305
1457
  mediaContent.revokeContentUrls(removedItem); // Free memory!
@@ -1320,9 +1472,7 @@ class TinyRadioFm extends events.EventEmitter {
1320
1472
  });
1321
1473
  }
1322
1474
 
1323
- if (listsMutated) {
1324
- this.#cycleCache.clear();
1325
- }
1475
+ if (listsMutated) this.#cycleCache.clear();
1326
1476
  }
1327
1477
 
1328
1478
  /**
@@ -1345,29 +1495,12 @@ class TinyRadioFm extends events.EventEmitter {
1345
1495
  }
1346
1496
  });
1347
1497
 
1348
- const processListForImport = (/** @type {MediaContent[]} */ list) => {
1349
- return list.map((item) => {
1350
- const newItem = { ...item };
1351
- if (newItem.picture && Array.isArray(newItem.picture)) {
1352
- newItem.picture = newItem.picture.map((pic) => {
1353
- // Check whether the data is compatible with the Blob structure (Base64 or Binary)
1354
- const isBase64 = typeof pic.data === 'string' && pic.data.startsWith('data:');
1355
- const isUint8Array = pic.data instanceof Uint8Array;
1356
-
1357
- if (isBase64 || isUint8Array) {
1358
- return {
1359
- ...pic,
1360
- data: mediaContent.convertToBlobUrl(pic.data, pic.format),
1361
- };
1362
- }
1363
-
1364
- // If it is a default web URL or local file, use the URL directly
1365
- return pic;
1366
- });
1367
- }
1498
+ const processListForImport = (/** @type {MediaContent[]} */ list) =>
1499
+ list.map((item) => {
1500
+ const newItem = structuredClone(item);
1501
+ this._ccBase64ToBlobUrl(newItem);
1368
1502
  return newItem;
1369
1503
  });
1370
- };
1371
1504
 
1372
1505
  this.#musicList = processListForImport(data.music);
1373
1506
  this.#voiceList = processListForImport(data.voice);