tiny-essentials 1.26.4 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/.github/workflows/node.js.yml +1 -1
  2. package/README.md +0 -1
  3. package/changelog/1/27/0.md +24 -0
  4. package/dist/v1/ColorSafeStringify.min.js +1 -1
  5. package/dist/v1/TinyAdvancedRaffle.min.js +1 -1
  6. package/dist/v1/TinyAfterScrollWatcher.min.js +1 -1
  7. package/dist/v1/TinyAnalogClock.min.js +1 -1
  8. package/dist/v1/TinyArrayComparator.min.js +1 -1
  9. package/dist/v1/TinyArrayPaginator.min.js +1 -1
  10. package/dist/v1/TinyBasicsEs.min.js +1 -1
  11. package/dist/v1/TinyClassManager.min.js +1 -1
  12. package/dist/v1/TinyClipboard.min.js +1 -1
  13. package/dist/v1/TinyColorConverter.min.js +1 -1
  14. package/dist/v1/TinyColorValidator.min.js +1 -1
  15. package/dist/v1/TinyCookieConsent.min.js +1 -1
  16. package/dist/v1/TinyDayNightCycle.min.js +1 -1
  17. package/dist/v1/TinyDomReadyManager.min.js +1 -1
  18. package/dist/v1/TinyDragDropDetector.min.js +1 -1
  19. package/dist/v1/TinyDragger.min.js +1 -1
  20. package/dist/v1/TinyElementObserver.min.js +1 -1
  21. package/dist/v1/TinyEssentials.min.js +1 -1
  22. package/dist/v1/TinyEvents.min.js +1 -1
  23. package/dist/v1/TinyGamepad.min.js +1 -1
  24. package/dist/v1/TinyHtml.min.js +1 -1
  25. package/dist/v1/TinyI18.min.js +1 -1
  26. package/dist/v1/TinyIframeEvents.min.js +1 -1
  27. package/dist/v1/TinyInventory.min.js +1 -1
  28. package/dist/v1/TinyInventoryTrader.min.js +1 -1
  29. package/dist/v1/TinyLevelUp.min.js +1 -1
  30. package/dist/v1/TinyLoadingScreen.min.js +1 -1
  31. package/dist/v1/TinyLocalStorage.min.js +1 -1
  32. package/dist/v1/TinyMaInSys.min.js +1 -1
  33. package/dist/v1/TinyMediaPlayer.min.js +1 -0
  34. package/dist/v1/TinyNeedBar.min.js +1 -1
  35. package/dist/v1/TinyNewWinEvents.min.js +1 -1
  36. package/dist/v1/TinyNotifications.min.js +1 -1
  37. package/dist/v1/TinyNotifyCenter.min.js +1 -1
  38. package/dist/v1/TinyPromiseQueue.min.js +1 -1
  39. package/dist/v1/TinyRadioFm.min.js +1 -0
  40. package/dist/v1/TinyRateLimiter.min.js +1 -1
  41. package/dist/v1/TinySimpleDice.min.js +1 -1
  42. package/dist/v1/TinySmartScroller.min.js +1 -1
  43. package/dist/v1/TinyTextDiffer.min.js +1 -1
  44. package/dist/v1/TinyTextRangeEditor.min.js +1 -1
  45. package/dist/v1/TinyTextarea.min.js +1 -1
  46. package/dist/v1/TinyTimeout.min.js +1 -1
  47. package/dist/v1/TinyToastNotify.min.js +1 -1
  48. package/dist/v1/TinyUploadClicker.min.js +1 -1
  49. package/dist/v1/UltraRandomMsgGen.min.js +1 -1
  50. package/dist/v1/basics/index.cjs +5 -0
  51. package/dist/v1/basics/index.d.mts +5 -1
  52. package/dist/v1/basics/index.mjs +2 -1
  53. package/dist/v1/basics/mediaContent.cjs +644 -0
  54. package/dist/v1/basics/mediaContent.d.mts +296 -0
  55. package/dist/v1/basics/mediaContent.mjs +557 -0
  56. package/dist/v1/build/TinyMediaPlayer.cjs +7 -0
  57. package/dist/v1/build/TinyMediaPlayer.d.mts +3 -0
  58. package/dist/v1/build/TinyMediaPlayer.mjs +2 -0
  59. package/dist/v1/build/TinyRadioFm.cjs +7 -0
  60. package/dist/v1/build/TinyRadioFm.d.mts +3 -0
  61. package/dist/v1/build/TinyRadioFm.mjs +2 -0
  62. package/dist/v1/index.cjs +9 -0
  63. package/dist/v1/index.d.mts +7 -1
  64. package/dist/v1/index.mjs +5 -2
  65. package/dist/v1/libs/ColorSafeStringify.d.mts +1 -1
  66. package/dist/v1/libs/TinyAfterScrollWatcher.cjs +16 -3
  67. package/dist/v1/libs/TinyAfterScrollWatcher.d.mts +7 -3
  68. package/dist/v1/libs/TinyAfterScrollWatcher.mjs +13 -3
  69. package/dist/v1/libs/TinyArrayComparator.cjs +14 -11
  70. package/dist/v1/libs/TinyArrayComparator.d.mts +22 -19
  71. package/dist/v1/libs/TinyArrayComparator.mjs +14 -11
  72. package/dist/v1/libs/TinyArrayPaginator.cjs +23 -16
  73. package/dist/v1/libs/TinyArrayPaginator.d.mts +57 -30
  74. package/dist/v1/libs/TinyArrayPaginator.mjs +22 -16
  75. package/dist/v1/libs/TinyClassManager/TinyPluginInliner.cjs +265 -0
  76. package/dist/v1/libs/TinyClassManager/TinyPluginInliner.d.mts +92 -0
  77. package/dist/v1/libs/TinyClassManager/TinyPluginInliner.mjs +223 -0
  78. package/dist/v1/libs/TinyClassManager.cjs +52 -22
  79. package/dist/v1/libs/TinyClassManager.d.mts +26 -16
  80. package/dist/v1/libs/TinyClassManager.mjs +50 -21
  81. package/dist/v1/libs/TinyColorValidator.d.mts +16 -16
  82. package/dist/v1/libs/TinyDragDropDetector.cjs +9 -34
  83. package/dist/v1/libs/TinyDragDropDetector.d.mts +7 -21
  84. package/dist/v1/libs/TinyDragDropDetector.mjs +9 -24
  85. package/dist/v1/libs/TinyDragger.cjs +5 -5
  86. package/dist/v1/libs/TinyDragger.d.mts +4 -3
  87. package/dist/v1/libs/TinyDragger.mjs +5 -5
  88. package/dist/v1/libs/TinyElementObserver.cjs +6 -21
  89. package/dist/v1/libs/TinyElementObserver.d.mts +6 -15
  90. package/dist/v1/libs/TinyElementObserver.mjs +6 -20
  91. package/dist/v1/libs/TinyGamepad.cjs +4 -6
  92. package/dist/v1/libs/TinyGamepad.d.mts +1 -1
  93. package/dist/v1/libs/TinyHtml/TinyHtmlIcon.d.mts +1 -1
  94. package/dist/v1/libs/TinyHtml/TinyHtmlTemplate.d.mts +1 -1
  95. package/dist/v1/libs/TinyHtml/TinyHtmlTextarea.d.mts +1 -1
  96. package/dist/v1/libs/TinyHtml/index.d.mts +1 -1
  97. package/dist/v1/libs/TinyHtml.cjs +72 -98
  98. package/dist/v1/libs/TinyHtml.d.mts +15 -32
  99. package/dist/v1/libs/TinyHtml.mjs +53 -77
  100. package/dist/v1/libs/TinyInventory.d.mts +1 -1
  101. package/dist/v1/libs/TinyMediaPlayer.cjs +844 -0
  102. package/dist/v1/libs/TinyMediaPlayer.d.mts +306 -0
  103. package/dist/v1/libs/TinyMediaPlayer.mjs +755 -0
  104. package/dist/v1/libs/TinyRadioFm.cjs +1378 -0
  105. package/dist/v1/libs/TinyRadioFm.d.mts +554 -0
  106. package/dist/v1/libs/TinyRadioFm.mjs +1137 -0
  107. package/dist/v1/libs/TinySmartScroller.cjs +39 -26
  108. package/dist/v1/libs/TinySmartScroller.d.mts +16 -62
  109. package/dist/v1/libs/TinySmartScroller.mjs +35 -26
  110. package/dist/v1/libs/UltraRandomMsgGen.d.mts +7 -7
  111. package/docs/v1/README.md +6 -0
  112. package/docs/v1/basics/mediaContent.md +131 -0
  113. package/docs/v1/libs/TinyClassManager/TinyPluginInliner.md +107 -0
  114. package/docs/v1/libs/TinyDragDropDetector.md +5 -9
  115. package/docs/v1/libs/TinyElementObserver.md +3 -6
  116. package/docs/v1/libs/TinyHtml.md +0 -29
  117. package/docs/v1/libs/TinyMediaPlayer.md +100 -0
  118. package/docs/v1/libs/TinyRadioFm.md +141 -0
  119. package/docs/v1/libs/TinySmartScroller.md +3 -3
  120. package/package.json +40 -22
@@ -0,0 +1,1378 @@
1
+ 'use strict';
2
+
3
+ var mediaContent = require('../basics/mediaContent.cjs');
4
+ var TinyEvents = require('./TinyEvents.cjs');
5
+
6
+ /**
7
+ * @typedef {import('../basics/mediaContent.mjs').MediaContentBase} MediaContentBase
8
+ * @typedef {import('../basics/mediaContent.mjs').MediaContentMetadata} MediaContentMetadata
9
+ * @typedef {import('../basics/mediaContent.mjs').MediaContent} MediaContent
10
+ * @typedef {import('../basics/mediaContent.mjs').IPicture} IPicture
11
+ * @typedef {import('../basics/mediaContent.mjs').MediaNumber} MediaNumber
12
+ * @typedef {import('../basics/mediaContent.mjs').MediaLoadingError} MediaLoadingError
13
+ * @typedef {import('../basics/mediaContent.mjs').MediaLoadingErrorData} MediaLoadingErrorData
14
+ * @typedef {import('../basics/mediaContent.mjs').LoadingMediaProgress} LoadingMediaProgress
15
+ * @typedef {import('../basics/mediaContent.mjs').ParseMediaContentMetadata} ParseMediaContentMetadata
16
+ * @typedef {import('../basics/mediaContent.mjs').UnknownArtistGetter} UnknownArtistGetter
17
+ */
18
+
19
+ //////////////////////////////////////////////////////////////////
20
+
21
+ /**
22
+ * Represents a content item injected at a specific point in the absolute timeline.
23
+ * @typedef {Object} CustomPosition
24
+ * @property {MediaContent} content - The audio/music content.
25
+ * @property {number} intendedTimestamp - The absolute Date.now() target.
26
+ * @property {number} originalTimestamp - The timestamp preserved for intelligent repositioning.
27
+ */
28
+
29
+ //////////////////////////////////////////////////////////////////
30
+
31
+ /**
32
+ * Data required to relocate an existing item within a playlist.
33
+ * @typedef {Object} ScheduledMovePayload
34
+ * @property {string} id - Content ID to move.
35
+ * @property {number} newIndex - The target index in the playlist.
36
+ */
37
+
38
+ /**
39
+ * A union type representing the various data formats a scheduled task payload can take.
40
+ * @typedef {MediaContent | string | ScheduledMovePayload} ScheduledTaskPayload
41
+ */
42
+
43
+ /**
44
+ * A scheduled instruction to modify the radio state at a specific point in time.
45
+ * @typedef {Object} ScheduledTask
46
+ * @property {number} timestamp - The absolute time to execute the action.
47
+ * @property {'add'|'remove'|'move'} action - The type of modification.
48
+ * @property {'music'|'voice'} type - Target playlist.
49
+ * @property {ScheduledTaskPayload} payload - Data relative to the action.
50
+ */
51
+
52
+ //////////////////////////////////////////////////////////////////
53
+
54
+ /**
55
+ * A standardized representation of an active or upcoming event in the radio timeline.
56
+ * @typedef {Object} RadioEvent
57
+ * @property {string} id - Content ID.
58
+ * @property {string} title - Content title.
59
+ * @property {string} artist - Content artist.
60
+ * @property {string} url - Source URL/Path.
61
+ * @property {number} duration - Total duration of the event.
62
+ * @property {number} absoluteStart - Start timestamp within the absolute timeline.
63
+ * @property {number} absoluteEnd - End timestamp within the absolute timeline.
64
+ * @property {number} elapsedTime - How many ms have passed since the event started.
65
+ * @property {number} remainingTime - How many ms are left until the event ends.
66
+ * @property {number} progress - Percentage of completion (0 to 1).
67
+ * @property {boolean} isCustom - Whether this is a user-defined custom position.
68
+ */
69
+
70
+ /**
71
+ * The available sequence logic modes for playlist playback.
72
+ * @typedef {'playlist'|'random'} RadioModes
73
+ */
74
+
75
+ /**
76
+ * Global configuration settings for the radio engine behavior.
77
+ * @typedef {Object} RadioConfig
78
+ * @property {RadioModes} mode - Sequence mode for music.
79
+ * @property {RadioModes} voiceMode - Sequence mode for voices.
80
+ * @property {number} silenceDuration - Gap in ms between tracks.
81
+ * @property {number} queryLimit - Safety lock for max items processed.
82
+ * @property {boolean} voiceAfterMusic - Whether to play voice messages after music tracks.
83
+ * @property {number} voiceMin - Minimum amount of voice messages to play if voiceAfterMusic is true.
84
+ * @property {number} voiceMax - Maximum amount of voice messages to play.
85
+ * @property {number} musicMaxConsecutive - Max times a music track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
86
+ * @property {number} voiceMaxConsecutive - Max times a voice track can repeat consecutively (-1 = unlimited, 0 = strictly no repetition).
87
+ */
88
+
89
+ //////////////////////////////////////////////////////////////////
90
+
91
+ /**
92
+ * An extension of MediaContent that includes temporal boundaries within a generated cycle.
93
+ * @typedef {MediaContent & { cycleStart: number; cycleEnd: number; }} CycleBlockData
94
+ */
95
+
96
+ /**
97
+ * A structural block representing a single full iteration of the radio sequence.
98
+ * @typedef {Object} CycleBlock
99
+ * @property {CycleBlockData[]} items - Items belonging to this cycle.
100
+ * @property {number} duration - Total duration of the cycle block in ms.
101
+ */
102
+
103
+ /**
104
+ * Information about the location of a specific cycle within the absolute timeline.
105
+ * @typedef {Object} CycleLocation
106
+ * @property {CycleBlock} block - The located cycle block.
107
+ * @property {number} startTimestamp - The absolute start time of this cycle.
108
+ * @property {number} loopIndex - The specific loop iteration index.
109
+ */
110
+
111
+ //////////////////////////////////////////////////////////////////
112
+
113
+ /**
114
+ * The complete state object used for exporting and importing the radio system.
115
+ * @typedef {Object} TinyRadioFmImport
116
+ * @property {MediaContent[]} music - The music playlist.
117
+ * @property {MediaContent[]} voice - The voice playlist.
118
+ * @property {CustomPosition[]} custom - The custom position injections.
119
+ * @property {ScheduledTask[]} tasks - The pending scheduled tasks.
120
+ * @property {number} seed - The randomness seed.
121
+ * @property {number} anchorDate - The timeline anchor timestamp.
122
+ * @property {RadioConfig} config - The engine configuration.
123
+ */
124
+
125
+ //////////////////////////////////////////////////////////////////
126
+
127
+ /**
128
+ * A deterministic, seed-based radio management system with scheduled adaptations and weighted random generation.
129
+ * @extends TinyEvents
130
+ * @beta
131
+ */
132
+ class TinyRadioFm extends TinyEvents {
133
+ /**
134
+ * A Static Factory Method that prepares a MediaContent object by
135
+ * extracting metadata from an audio source.
136
+ *
137
+ * @param {string | HTMLMediaElement} source - A URL string or an existing Audio object.
138
+ * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [defaultMetadata={}] - Optional default metadata that overrides automatic extraction.
139
+ * @param {Partial<MediaContentBase & MediaContentMetadata> & { id?: string; weight?: number }} [metadata={}] - Optional manual metadata that overrides automatic extraction.
140
+ * @param {ParseMediaContentMetadata} [parseFile] - Private helper to interface with parseFile.
141
+ * @param {Object} [callbacks={}] - Callbacks for monitoring the loading process.
142
+ * @param {(progress: LoadingMediaProgress) => void} [callbacks.onProgress] - Callback triggered on stage changes.
143
+ * @param {(error: MediaLoadingErrorData) => void} [callbacks.onError] - Callback triggered when a non-fatal or fatal error occurs.
144
+ * @returns {Promise<MediaContent>} A promise that resolves to a valid MediaContent object.
145
+ * @throws {MediaLoadingError} If the preparation process fails at any stage.
146
+ */
147
+ static async parseContent(source, defaultMetadata, metadata, parseFile, callbacks) {
148
+ return mediaContent.parseMediaMetadata(
149
+ source,
150
+ defaultMetadata,
151
+ metadata,
152
+ parseFile,
153
+ callbacks,
154
+ TinyRadioFm.#unknownArtist,
155
+ );
156
+ }
157
+
158
+ /**
159
+ * @type {UnknownArtistGetter}
160
+ * The default identifier or function used when an artist cannot be determined.
161
+ */
162
+ static #unknownArtist = 'Unknown Artist';
163
+
164
+ /**
165
+ * Gets the current value used to represent unknown artists.
166
+ * @returns {UnknownArtistGetter}
167
+ */
168
+ static get unknownArtist() {
169
+ return TinyRadioFm.#unknownArtist;
170
+ }
171
+
172
+ /**
173
+ * Sets the value used to represent unknown artists.
174
+ * @param {UnknownArtistGetter} value - A string or a function that returns a string.
175
+ * @throws {TypeError} If the value is neither a string nor a function.
176
+ */
177
+ static set unknownArtist(value) {
178
+ if (typeof value !== 'string' && typeof value !== 'function')
179
+ throw new TypeError('unknownArtist must have an string or function.');
180
+ TinyRadioFm.#unknownArtist = value;
181
+ }
182
+
183
+ /**
184
+ * Gets the total count of all content items (music and voice) in the system.
185
+ * @returns {number}
186
+ */
187
+ get size() {
188
+ return this.#musicList.length + this.#voiceList.length;
189
+ }
190
+
191
+ /**
192
+ * Gets the total number of items in the music playlist.
193
+ * @returns {number}
194
+ */
195
+ get musicSize() {
196
+ return this.#musicList.length;
197
+ }
198
+
199
+ /**
200
+ * Gets the total number of items in the voice playlist.
201
+ * @returns {number}
202
+ */
203
+ get voiceSize() {
204
+ return this.#voiceList.length;
205
+ }
206
+
207
+ /**
208
+ * Gets the number of active custom position injections.
209
+ * @returns {number}
210
+ */
211
+ get customPosSize() {
212
+ return this.#customPositions.length;
213
+ }
214
+
215
+ /**
216
+ * Gets the number of pending scheduled tasks.
217
+ * @returns {number}
218
+ */
219
+ get tasksSize() {
220
+ return this.#scheduledTasks.length;
221
+ }
222
+
223
+ /**
224
+ * Gets the number of items currently stored in the cycle cache.
225
+ * @returns {number}
226
+ */
227
+ get cycleCacheSize() {
228
+ return this.#cycleCache.size;
229
+ }
230
+
231
+ /** @type {MediaContent[]} */
232
+ #musicList = [];
233
+ /**
234
+ * Gets a deep clone of the music playlist.
235
+ * @returns {MediaContent[]}
236
+ */
237
+ get musicList() {
238
+ return structuredClone(this.#musicList);
239
+ }
240
+
241
+ /** @type {MediaContent[]} */
242
+ #voiceList = [];
243
+ /**
244
+ * Gets a deep clone of the voice playlist.
245
+ * @returns {MediaContent[]}
246
+ */
247
+ get voiceList() {
248
+ return structuredClone(this.#voiceList);
249
+ }
250
+
251
+ /** @type {CustomPosition[]} */
252
+ #customPositions = [];
253
+ /**
254
+ * Gets a deep clone of the custom position injections.
255
+ * @returns {CustomPosition[]}
256
+ */
257
+ get customPositions() {
258
+ return structuredClone(this.#customPositions);
259
+ }
260
+
261
+ /** @type {ScheduledTask[]} */
262
+ #scheduledTasks = [];
263
+ /**
264
+ * Gets a deep clone of the scheduled tasks.
265
+ * @returns {ScheduledTask[]}
266
+ */
267
+ get scheduledTasks() {
268
+ return structuredClone(this.#scheduledTasks);
269
+ }
270
+
271
+ /**
272
+ * Returns a deep clone of the internal all list cache.
273
+ * @returns {MediaContent[]} A cloned object of the cache.
274
+ */
275
+ get allList() {
276
+ return [...this.musicList, ...this.voiceList];
277
+ }
278
+
279
+ /** @type {number} */
280
+ #seed = 0;
281
+ /**
282
+ * Gets the current randomness seed.
283
+ * @returns {number}
284
+ */
285
+ get seed() {
286
+ return this.#seed;
287
+ }
288
+ /**
289
+ * Sets the core randomness seed and clears the current cycle cache.
290
+ * @param {number} seed - The new seed.
291
+ */
292
+ set seed(seed) {
293
+ if (typeof seed !== 'number') throw new TypeError('Seed must be a number.');
294
+ this.#seed = seed;
295
+ this.#cycleCache.clear();
296
+ this.emit('seedChanged', { seed });
297
+ }
298
+
299
+ /** @type {number} */
300
+ #anchorDate = Date.now();
301
+ /**
302
+ * Gets the absolute timestamp used as the timeline anchor.
303
+ * @returns {number}
304
+ */
305
+ get anchorDate() {
306
+ return this.#anchorDate;
307
+ }
308
+
309
+ /** @type {Map<number, CycleBlock>} */
310
+ #cycleCache = new Map();
311
+
312
+ /** @type {RadioConfig} */
313
+ #config = {
314
+ mode: 'playlist',
315
+ voiceMode: 'playlist',
316
+ silenceDuration: 0,
317
+ queryLimit: 100000,
318
+ voiceAfterMusic: true,
319
+ voiceMin: 0,
320
+ voiceMax: 1,
321
+ musicMaxConsecutive: 0,
322
+ voiceMaxConsecutive: 0,
323
+ };
324
+ /**
325
+ * Gets a deep clone of the current radio configuration.
326
+ * @returns {RadioConfig}
327
+ */
328
+ get config() {
329
+ return structuredClone(this.#config);
330
+ }
331
+ /**
332
+ * Performs a complete replacement of the configuration.
333
+ * @param {RadioConfig} config - The new full configuration object.
334
+ * @throws {TypeError|RangeError} If the new configuration is invalid.
335
+ */
336
+ set config(config) {
337
+ // Validate the entire object before applying it
338
+ this.#validateConfig(config);
339
+
340
+ this.#config = structuredClone(config);
341
+ this.#cycleCache.clear();
342
+ this.emit('configChanged', { config: this.config });
343
+ }
344
+
345
+ /**
346
+ * Validates the integrity and logical consistency of a RadioConfig object.
347
+ *
348
+ * @param {Partial<RadioConfig> | RadioConfig} config - The configuration object to validate.
349
+ * @throws {TypeError} If a property has an incorrect type.
350
+ * @throws {RangeError} If a numeric value is out of allowed bounds or logically inconsistent.
351
+ */
352
+ #validateConfig(config) {
353
+ if (typeof config !== 'object' || config === null) {
354
+ throw new TypeError('Configuration must be a valid object.');
355
+ }
356
+
357
+ const modes = ['playlist', 'random'];
358
+
359
+ // 1. Type and Bounds Validation
360
+ if (config.mode !== undefined && !modes.includes(config.mode)) {
361
+ throw new TypeError(`Invalid mode: "${config.mode}". Must be one of: ${modes.join(', ')}.`);
362
+ }
363
+
364
+ if (config.voiceMode !== undefined && !modes.includes(config.voiceMode)) {
365
+ throw new TypeError(
366
+ `Invalid voiceMode: "${config.voiceMode}". Must be one of: ${modes.join(', ')}.`,
367
+ );
368
+ }
369
+
370
+ if (
371
+ config.silenceDuration !== undefined &&
372
+ (typeof config.silenceDuration !== 'number' || config.silenceDuration < 0)
373
+ ) {
374
+ throw new TypeError('silenceDuration must be a non-negative number.');
375
+ }
376
+
377
+ if (
378
+ config.queryLimit !== undefined &&
379
+ (typeof config.queryLimit !== 'number' || config.queryLimit <= 0)
380
+ ) {
381
+ throw new TypeError('queryLimit must be a positive number.');
382
+ }
383
+
384
+ if (config.voiceAfterMusic !== undefined && typeof config.voiceAfterMusic !== 'boolean') {
385
+ throw new TypeError('voiceAfterMusic must be a boolean.');
386
+ }
387
+
388
+ if (
389
+ config.voiceMin !== undefined &&
390
+ (typeof config.voiceMin !== 'number' || config.voiceMin < 0)
391
+ ) {
392
+ throw new TypeError('voiceMin must be a non-negative number.');
393
+ }
394
+
395
+ if (
396
+ config.voiceMax !== undefined &&
397
+ (typeof config.voiceMax !== 'number' || config.voiceMax < 0)
398
+ ) {
399
+ throw new TypeError('voiceMax must be a non-negative number.');
400
+ }
401
+
402
+ if (
403
+ config.musicMaxConsecutive !== undefined &&
404
+ (typeof config.musicMaxConsecutive !== 'number' || config.musicMaxConsecutive < -1)
405
+ ) {
406
+ throw new TypeError('musicMaxConsecutive must be a number >= -1.');
407
+ }
408
+
409
+ if (
410
+ config.voiceMaxConsecutive !== undefined &&
411
+ (typeof config.voiceMaxConsecutive !== 'number' || config.voiceMaxConsecutive < -1)
412
+ ) {
413
+ throw new TypeError('voiceMaxConsecutive must be a number >= -1.');
414
+ }
415
+
416
+ // 2. Logical Cross-Field Validation
417
+ const min = config.voiceMin ?? this.#config.voiceMin;
418
+ const max = config.voiceMax ?? this.#config.voiceMax;
419
+
420
+ if (max < min) {
421
+ throw new RangeError(
422
+ `Logical error: voiceMax (${max}) cannot be less than voiceMin (${min}).`,
423
+ );
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Initializes the radio system.
429
+ * @param {TinyRadioFmImport|null} [initialData=null] - JSON object to hydrate the radio state.
430
+ * @param {number} [seed=0] - Initial seed for deterministic randomness.
431
+ * @throws {TypeError} If initialData is not an object or null, or if seed is not a number.
432
+ */
433
+ constructor(initialData = null, seed = 0) {
434
+ super();
435
+ if (initialData !== null && typeof initialData !== 'object')
436
+ throw new TypeError('initialData must be an object or null.');
437
+ if (typeof seed !== 'number') throw new TypeError('seed must be a number.');
438
+
439
+ this.#seed = seed;
440
+
441
+ // Bootstraps the application state ensuring determinism based on the anchor.
442
+ if (initialData) {
443
+ this.#hydrate(initialData);
444
+ } else {
445
+ this.#anchorDate = Date.now();
446
+ }
447
+ }
448
+
449
+ // --- PUBLIC API ---
450
+
451
+ /**
452
+ * Adds new content instantly to the radio sequence.
453
+ * @param {'music'|'voice'|'custom'} type - The category of the content.
454
+ * @param {MediaContent & { timestamp?: number }} data - The content payload to insert.
455
+ * @param {boolean} [smartQueue=true] - If true, delays insertion until the end of the content playing at that timestamp (only affects 'custom').
456
+ * @throws {TypeError} If the type is invalid or the data lacks a valid ID and numerical duration.
457
+ */
458
+ add(type, data, smartQueue = true) {
459
+ if (!['music', 'voice', 'custom'].includes(type)) {
460
+ throw new TypeError('Type must be "music", "voice", or "custom".');
461
+ }
462
+ if (!data || typeof data.id !== 'string' || typeof data.duration !== 'number') {
463
+ throw new TypeError(
464
+ 'Content must have a string ID and a valid numerical duration in milliseconds.',
465
+ );
466
+ }
467
+ if (typeof smartQueue !== 'boolean') {
468
+ throw new TypeError('smartQueue must be a boolean.');
469
+ }
470
+
471
+ if (type === 'music') {
472
+ this.#musicList.push(data);
473
+ this.#cycleCache.clear();
474
+ } else if (type === 'voice') {
475
+ this.#voiceList.push(data);
476
+ this.#cycleCache.clear();
477
+ } else if (type === 'custom') {
478
+ this.#handleCustomInsertion(data, smartQueue);
479
+ }
480
+
481
+ this.#syncRealTimeState(Date.now());
482
+ this.emit('contentAdded', { type, data: structuredClone(data) });
483
+ }
484
+
485
+ /**
486
+ * Schedules a modification to the base playlists, seamlessly breaking the timeline when activated.
487
+ * @param {number} timestamp - Epoch timestamp in ms.
488
+ * @param {'add'|'remove'|'move'} action - Action to perform.
489
+ * @param {'music'|'voice'} type - Target list.
490
+ * @param {ScheduledTaskPayload} payload - Data relative to the action.
491
+ * @param {boolean} [smartQueue=true] - If true, delays the task execution until the end of the content playing at that timestamp.
492
+ * @throws {TypeError} If arguments do not match the required types or action/type constraints.
493
+ */
494
+ scheduleTask(timestamp, action, type, payload, smartQueue = true) {
495
+ if (typeof timestamp !== 'number' || isNaN(timestamp))
496
+ throw new TypeError('timestamp must be a valid number.');
497
+ if (!['add', 'remove', 'move'].includes(action))
498
+ throw new TypeError('action must be "add", "remove", or "move".');
499
+ if (!['music', 'voice'].includes(type)) throw new TypeError('type must be "music" or "voice".');
500
+ if (typeof smartQueue !== 'boolean') throw new TypeError('smartQueue must be a boolean.');
501
+
502
+ // Payload-specific validation based on action
503
+ if (action === 'add') {
504
+ if (
505
+ typeof payload !== 'object' ||
506
+ payload === null ||
507
+ typeof payload.id !== 'string' ||
508
+ // @ts-ignore
509
+ typeof payload.duration !== 'number'
510
+ ) {
511
+ throw new TypeError(
512
+ 'Payload for "add" must be a MediaContent object (id: string, duration: number).',
513
+ );
514
+ }
515
+ } else if (action === 'remove') {
516
+ if (typeof payload !== 'string') {
517
+ throw new TypeError('Payload for "remove" must be a string (the content ID).');
518
+ }
519
+ } else if (action === 'move') {
520
+ if (
521
+ typeof payload !== 'object' ||
522
+ payload === null ||
523
+ typeof payload.id !== 'string' ||
524
+ // @ts-ignore
525
+ typeof payload.newIndex !== 'number'
526
+ ) {
527
+ throw new TypeError(
528
+ 'Payload for "move" must be a ScheduledMovePayload (id: string, newIndex: number).',
529
+ );
530
+ }
531
+ }
532
+
533
+ let finalTimestamp = timestamp;
534
+
535
+ // Smart Adjustment Logic (Smart Queue)
536
+ if (smartQueue) {
537
+ // Check if there's anything playing exactly on the requested timestamp
538
+ const eventAtTime = this.#getEventAtTime(timestamp, timestamp);
539
+
540
+ // If there is, we set the timing of the task to the exact fraction of milliseconds
541
+ // in which this event ends before the next audio begins.
542
+ if (eventAtTime) {
543
+ finalTimestamp = eventAtTime.absoluteEnd;
544
+ }
545
+ }
546
+
547
+ /** @type {ScheduledTask} */
548
+ const task = { timestamp: finalTimestamp, action, type, payload };
549
+ this.#scheduledTasks.push(task);
550
+
551
+ // Synchronize the state in real time. If the endTimestamp is in the future,
552
+ // the task will remain securely pending without breaking the radio.
553
+ this.#syncRealTimeState(Date.now());
554
+
555
+ this.emit('taskScheduled', structuredClone(task));
556
+ }
557
+
558
+ /**
559
+ * Removes content instantly by ID across all active lists, positions, and future tasks.
560
+ * @param {string} id - The unique identifier of the content.
561
+ * @throws {TypeError} If the id is not a string.
562
+ */
563
+ remove(id) {
564
+ if (typeof id !== 'string') throw new TypeError('id must be a string.');
565
+
566
+ // Revoke Blob URLs of items being removed to free memory
567
+ this.#musicList.filter((item) => item.id === id).forEach((item) => mediaContent.revokeContentUrls(item));
568
+ this.#voiceList.filter((item) => item.id === id).forEach((item) => mediaContent.revokeContentUrls(item));
569
+ this.#customPositions
570
+ .filter((cp) => cp.content?.id === id)
571
+ .forEach((cp) => mediaContent.revokeContentUrls(cp.content));
572
+
573
+ /**
574
+ * Filter function to match items against the provided ID.
575
+ * @type {function(any): boolean}
576
+ */
577
+ const filterFn = (item) => item.id !== id && item.content?.id !== id;
578
+
579
+ this.#musicList = this.#musicList.filter(filterFn);
580
+ this.#voiceList = this.#voiceList.filter(filterFn);
581
+ this.#customPositions = this.#customPositions.filter(filterFn);
582
+
583
+ this.#scheduledTasks = this.#scheduledTasks.filter((t) => {
584
+ if (
585
+ t.action === 'add' &&
586
+ typeof t.payload === 'object' &&
587
+ t.payload !== null &&
588
+ 'id' in t.payload
589
+ ) {
590
+ if (t.payload.id === id) {
591
+ mediaContent.revokeContentUrls(/** @type {MediaContent} */ (t.payload));
592
+ return false;
593
+ }
594
+ return true;
595
+ }
596
+ return t.payload !== id;
597
+ });
598
+
599
+ this.#cycleCache.clear();
600
+ this.emit('contentRemoved', { id });
601
+ }
602
+
603
+ /**
604
+ * Performs a partial update of the configuration.
605
+ * @param {Partial<RadioConfig>} config - The configuration overrides.
606
+ * @throws {TypeError|RangeError} If the provided values or the resulting state is invalid.
607
+ */
608
+ setConfig(config) {
609
+ // First, validate the incoming partial object for basic type correctness
610
+ this.#validateConfig(config);
611
+
612
+ // Create the potential new state
613
+ const nextConfig = { ...this.#config, ...config };
614
+
615
+ // Second, validate the complete resulting state for logical consistency (e.g., min vs max)
616
+ this.#validateConfig(nextConfig);
617
+
618
+ this.#config = nextConfig;
619
+ this.#cycleCache.clear();
620
+ this.emit('configChanged', { config: this.config });
621
+ }
622
+
623
+ /**
624
+ * Retrieves the exact event playing at the current system time.
625
+ * @returns {RadioEvent|null} The current active event, or null if empty.
626
+ */
627
+ getCurrentEvent() {
628
+ const now = Date.now();
629
+ this.#syncRealTimeState(now);
630
+ return this.#getEventAtTime(now, now);
631
+ }
632
+
633
+ /**
634
+ * Queries the timeline from a specific date forward to predict upcoming events.
635
+ * Uses a virtual clone to predict scheduled tasks accurately without mutating current state.
636
+ * @param {number} targetDate - The starting epoch timestamp.
637
+ * @param {number} [limit=10] - Maximum number of upcoming events to resolve.
638
+ * @returns {RadioEvent[]} Array of resolved upcoming events.
639
+ * @throws {TypeError} If limit is not a number.
640
+ * @throws {RangeError} If the limit exceeds the configured queryLimit or is <= 0.
641
+ */
642
+ queryTimeline(targetDate, limit = 10) {
643
+ if (typeof limit !== 'number' || isNaN(limit)) {
644
+ throw new TypeError(`Invalid query limit value. Ensure it is a number.`);
645
+ }
646
+ if (limit > this.#config.queryLimit || limit <= 0) {
647
+ throw new RangeError(
648
+ `Invalid query limit. Ensure it is > 0 and <= ${this.#config.queryLimit}.`,
649
+ );
650
+ }
651
+
652
+ /**
653
+ * Virtual instance to sandbox the timeline prediction.
654
+ * @type {TinyRadioFm}
655
+ */
656
+ const virtualSandbox = new TinyRadioFm(JSON.parse(this._exportState()));
657
+
658
+ /** @type {RadioEvent[]} */
659
+ const events = [];
660
+ let currentTimeWalker = targetDate;
661
+
662
+ for (let i = 0; i < limit; i++) {
663
+ virtualSandbox.#syncRealTimeState(currentTimeWalker);
664
+ const nextEvent = virtualSandbox.#resolveNextEvent(currentTimeWalker, targetDate);
665
+ if (!nextEvent) break;
666
+
667
+ events.push(nextEvent);
668
+ currentTimeWalker = nextEvent.absoluteEnd;
669
+ }
670
+
671
+ virtualSandbox.destroy(false);
672
+ this.emit('timelineQueried', { targetDate, limit, resultCount: events.length });
673
+ return events;
674
+ }
675
+
676
+ /**
677
+ * Returns all active custom positions currently injected into the timeline.
678
+ * @returns {CustomPosition[]} Shallow copy of custom positions array.
679
+ */
680
+ searchCustomPositions() {
681
+ this.#syncRealTimeState(Date.now());
682
+ return [...this.#customPositions];
683
+ }
684
+
685
+ /**
686
+ * Process a content list, waiting to convert the images from Blob URL to Base64.
687
+ * @param {MediaContent[]} list
688
+ * @returns {Promise<MediaContent[]>}
689
+ * @private
690
+ */
691
+ async _processListForExport(list) {
692
+ return Promise.all(
693
+ list.map(async (item) => {
694
+ const newItem = structuredClone(item);
695
+ if (newItem.picture && Array.isArray(newItem.picture)) {
696
+ newItem.picture = await Promise.all(
697
+ newItem.picture.map(async (pic) => ({
698
+ ...pic,
699
+ data: await mediaContent.blobUrlToBase64(pic.data),
700
+ })),
701
+ );
702
+ }
703
+ return newItem;
704
+ }),
705
+ );
706
+ }
707
+
708
+ /**
709
+ * Exports the complete state of the radio, including caches and scheduled tasks.
710
+ * @returns {string} Stringified JSON state.
711
+ * @private
712
+ */
713
+ _exportState() {
714
+ return JSON.stringify({
715
+ music: this.#musicList,
716
+ voice: this.#voiceList,
717
+ custom: this.#customPositions,
718
+ tasks: this.#scheduledTasks,
719
+ seed: this.#seed,
720
+ anchorDate: this.#anchorDate,
721
+ config: this.#config,
722
+ });
723
+ }
724
+
725
+ /**
726
+ * Exports the complete state of the radio, including caches and scheduled tasks.
727
+ * @returns {Promise<string>} Stringified JSON state.
728
+ */
729
+ async exportState() {
730
+ const processedMusic = await this._processListForExport(this.#musicList);
731
+ const processedVoice = await this._processListForExport(this.#voiceList);
732
+
733
+ const processedCustom = await Promise.all(
734
+ this.#customPositions.map(async (cp) => {
735
+ const processedContent = await this._processListForExport([cp.content]);
736
+ return { ...cp, content: processedContent[0] };
737
+ }),
738
+ );
739
+
740
+ const processedTasks = await Promise.all(
741
+ this.#scheduledTasks.map(async (task) => {
742
+ if (
743
+ task.action === 'add' &&
744
+ task.payload &&
745
+ typeof task.payload === 'object' &&
746
+ 'title' in task.payload
747
+ ) {
748
+ const processedPayload = await this._processListForExport([
749
+ /** @type {MediaContent} */ (task.payload),
750
+ ]);
751
+ return { ...task, payload: processedPayload[0] };
752
+ }
753
+ return task;
754
+ }),
755
+ );
756
+
757
+ return JSON.stringify({
758
+ music: processedMusic,
759
+ voice: processedVoice,
760
+ custom: processedCustom,
761
+ tasks: processedTasks,
762
+ seed: this.#seed,
763
+ anchorDate: this.#anchorDate,
764
+ config: this.#config,
765
+ });
766
+ }
767
+
768
+ /**
769
+ * Imports a previously exported state, overwriting the current instance.
770
+ * @param {string|TinyRadioFmImport} json - JSON state or object.
771
+ * @throws {TypeError} If json is not a valid string or object.
772
+ */
773
+ importState(json) {
774
+ /** @type {TinyRadioFmImport} */
775
+ let data;
776
+ if (typeof json === 'string') {
777
+ try {
778
+ data = JSON.parse(json);
779
+ } catch {
780
+ throw new TypeError('Provided string is not valid JSON.');
781
+ }
782
+ } else if (typeof json === 'object' && json !== null) {
783
+ data = json;
784
+ } else {
785
+ throw new TypeError('Import data must be a valid JSON string or an object.');
786
+ }
787
+
788
+ this.#hydrate(data);
789
+ this.emit('stateImported', { data: structuredClone(data) });
790
+ }
791
+
792
+ /**
793
+ * Mulberry32 Pseudo-Random Number Generator.
794
+ * @param {number} seed - The initialization seed.
795
+ * @returns {function(): number} PRNG function returning a float between 0 and 1.
796
+ * @private
797
+ */
798
+ _prng(seed) {
799
+ return function () {
800
+ let t = (seed += 0x6d2b79f5);
801
+ t = Math.imul(t ^ (t >>> 15), t | 1);
802
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
803
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
804
+ };
805
+ }
806
+
807
+ // --- PRIVATE LOGIC ---
808
+
809
+ /**
810
+ * Creates a deterministic sequence supporting weighted selection based on mode.
811
+ * @param {MediaContent[]} list - The source list to sequence.
812
+ * @param {number} currentSeed - Cycle-specific seed.
813
+ * @param {RadioModes} mode - Processing mode.
814
+ * @param {number} [maxConsecutive=0] - Max consecutive repetitions permitted (-1 = unlimited, 0 = strictly no repeat).
815
+ * @returns {MediaContent[]} The generated sequence.
816
+ */
817
+ #buildSequence(list, currentSeed, mode, maxConsecutive = 0) {
818
+ if (list.length === 0) return [];
819
+ if (mode !== 'random') return [...list]; // Respects manual indexing/moving
820
+
821
+ /**
822
+ * Pool of available items for weighted selection.
823
+ * @type {Array<MediaContent & { weight: number }>}
824
+ */
825
+ const pool = list.map((item) => ({ ...item, weight: item.weight ?? 1 }));
826
+ const random = this._prng(currentSeed);
827
+
828
+ /**
829
+ * The finalized deterministic sequence.
830
+ * @type {MediaContent[]}
831
+ */
832
+ const sequence = [];
833
+
834
+ /** @type {string|null} */
835
+ let lastId = null;
836
+ let consecutiveCount = 0;
837
+
838
+ while (pool.length > 0) {
839
+ let validPool = pool;
840
+
841
+ // If there is a restriction rule and the consecutive limit has been reached
842
+ if (maxConsecutive !== -1 && lastId !== null && consecutiveCount > maxConsecutive) {
843
+ // Try filtering the last played to force the rotation
844
+ validPool = pool.filter((item) => item.id !== lastId);
845
+
846
+ // Self-adjustment: If there is nothing left in the urn (small/no diversity playlist),
847
+ // we revert to the full pool so as not to stagnate the cycle.
848
+ if (validPool.length === 0) {
849
+ validPool = pool;
850
+ }
851
+ }
852
+
853
+ const totalWeight = validPool.reduce((sum, item) => sum + item.weight, 0);
854
+
855
+ // Safety catch if all weights are 0
856
+ if (totalWeight <= 0) {
857
+ sequence.push(...validPool);
858
+ break;
859
+ }
860
+
861
+ const r = random() * totalWeight;
862
+ let sum = 0;
863
+ /** @type {MediaContent|null} */
864
+ let selectedItem = null;
865
+ let selectedIndex = -1;
866
+
867
+ // Run the draw on the valid urn
868
+ for (let i = 0; i < validPool.length; i++) {
869
+ sum += validPool[i].weight;
870
+ if (r <= sum) {
871
+ selectedItem = validPool[i];
872
+ // Find the index in the original pool for correct removal
873
+ selectedIndex = pool.findIndex((p) => selectedItem && p.id === selectedItem.id);
874
+ break;
875
+ }
876
+ }
877
+
878
+ // Fallback in case of mathematical failure in floating accuracy
879
+ if (!selectedItem || selectedIndex === -1) {
880
+ selectedItem = validPool[0];
881
+ selectedIndex = pool.findIndex((p) => selectedItem && p.id === selectedItem.id);
882
+ }
883
+
884
+ sequence.push(selectedItem);
885
+ pool.splice(selectedIndex, 1);
886
+
887
+ // Update counters to the next loop step
888
+ if (selectedItem.id === lastId) {
889
+ consecutiveCount++;
890
+ } else {
891
+ lastId = selectedItem.id;
892
+ consecutiveCount = 1;
893
+ }
894
+ }
895
+
896
+ return sequence;
897
+ }
898
+
899
+ /**
900
+ * Generates a structural block representing a single full cycle of the radio.
901
+ * @param {number} loopIndex - The current cycle iteration to generate appropriate seeds.
902
+ * @returns {CycleBlock} The generated cycle block containing sequenced items and total duration.
903
+ */
904
+ #buildCycleBlock(loopIndex) {
905
+ const cycleSeed = this.#seed + loopIndex;
906
+ const mixRandom = this._prng(cycleSeed * 10);
907
+
908
+ const musicSeq = this.#buildSequence(
909
+ this.#musicList,
910
+ cycleSeed + 1,
911
+ this.#config.mode,
912
+ this.#config.musicMaxConsecutive,
913
+ );
914
+ const voiceSeq = this.#buildSequence(
915
+ this.#voiceList,
916
+ cycleSeed + 2,
917
+ this.#config.voiceMode,
918
+ this.#config.voiceMaxConsecutive,
919
+ );
920
+
921
+ /**
922
+ * Array containing the positioned items for the current cycle.
923
+ * @type {CycleBlockData[]}
924
+ */
925
+ const block = [];
926
+ let cycleDuration = 0;
927
+ let voiceCursor = 0;
928
+
929
+ for (let mIdx = 0; mIdx < musicSeq.length; mIdx++) {
930
+ const music = musicSeq[mIdx];
931
+ block.push({
932
+ ...music,
933
+ cycleStart: cycleDuration,
934
+ cycleEnd: cycleDuration + music.duration,
935
+ });
936
+ cycleDuration += music.duration + this.#config.silenceDuration;
937
+
938
+ if (this.#config.voiceAfterMusic && voiceSeq.length > 0) {
939
+ const range = this.#config.voiceMax - this.#config.voiceMin + 1;
940
+ const voiceAmount = Math.floor(mixRandom() * range) + this.#config.voiceMin;
941
+
942
+ for (let v = 0; v < voiceAmount; v++) {
943
+ const voice = voiceSeq[voiceCursor % voiceSeq.length];
944
+ voiceCursor++;
945
+
946
+ block.push({
947
+ ...voice,
948
+ cycleStart: cycleDuration,
949
+ cycleEnd: cycleDuration + voice.duration,
950
+ });
951
+ cycleDuration += voice.duration + this.#config.silenceDuration;
952
+ }
953
+ }
954
+ }
955
+
956
+ return { items: block, duration: cycleDuration };
957
+ }
958
+
959
+ /**
960
+ * Fast-forwards to find the exact cycle encompassing the target absolute timestamp.
961
+ * @param {number} targetAbsoluteTime - The absolute epoch timestamp to locate.
962
+ * @returns {CycleLocation|null} The resolved cycle location or null if lists are empty.
963
+ * @throws {Error} If the loop safety limit is hit.
964
+ */
965
+ #locateCycleForTime(targetAbsoluteTime) {
966
+ if (this.#musicList.length === 0) return null;
967
+
968
+ let walkerAnchor = this.#anchorDate;
969
+ let loopIdx = 0;
970
+
971
+ while (true) {
972
+ if (loopIdx > this.#config.queryLimit) {
973
+ throw new Error('Safety limit hit during cycle location.');
974
+ }
975
+
976
+ let blockData = this.#cycleCache.get(loopIdx);
977
+ if (!blockData) {
978
+ blockData = this.#buildCycleBlock(loopIdx);
979
+ this.#cycleCache.set(loopIdx, blockData);
980
+ }
981
+
982
+ if (blockData.duration === 0) return null;
983
+
984
+ if (
985
+ targetAbsoluteTime >= walkerAnchor &&
986
+ targetAbsoluteTime < walkerAnchor + blockData.duration
987
+ ) {
988
+ return { block: blockData, startTimestamp: walkerAnchor, loopIndex: loopIdx };
989
+ }
990
+
991
+ walkerAnchor += blockData.duration;
992
+ loopIdx++;
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Orchestrates overlap checking to find the absolute closest next event.
998
+ * @param {number} walkerTime - The internal time cursor during queries.
999
+ * @param {number} originalTargetDate - The initial requested epoch target.
1000
+ * @returns {RadioEvent|null} The resolved next event.
1001
+ */
1002
+ #resolveNextEvent(walkerTime, originalTargetDate) {
1003
+ /**
1004
+ * @type {CustomPosition|undefined}
1005
+ */
1006
+ const nextCustomPos = this.#customPositions
1007
+ .filter((cp) => cp.intendedTimestamp + cp.content.duration > walkerTime)
1008
+ .sort((a, b) => a.intendedTimestamp - b.intendedTimestamp)[0];
1009
+
1010
+ let customEvent = null;
1011
+ if (nextCustomPos) {
1012
+ const qTime = Math.max(originalTargetDate, nextCustomPos.intendedTimestamp);
1013
+ customEvent = this.#formatEvent(
1014
+ nextCustomPos.content,
1015
+ nextCustomPos.intendedTimestamp,
1016
+ qTime,
1017
+ true,
1018
+ );
1019
+ }
1020
+
1021
+ const baseEvent = this.#getNextBaseEvent(walkerTime, originalTargetDate);
1022
+
1023
+ if (!baseEvent && !customEvent) return null;
1024
+ if (!baseEvent) return customEvent;
1025
+ if (!customEvent) return baseEvent;
1026
+
1027
+ if (customEvent.absoluteStart <= baseEvent.absoluteStart) return customEvent;
1028
+
1029
+ // Truncate base event if an impending custom event overlaps it
1030
+ if (customEvent.absoluteStart < baseEvent.absoluteEnd) {
1031
+ baseEvent.absoluteEnd = customEvent.absoluteStart;
1032
+ baseEvent.duration = baseEvent.absoluteEnd - baseEvent.absoluteStart;
1033
+ baseEvent.remainingTime = Math.max(0, baseEvent.absoluteEnd - originalTargetDate);
1034
+ baseEvent.progress = Math.min(1, baseEvent.elapsedTime / baseEvent.duration);
1035
+ }
1036
+
1037
+ return baseEvent;
1038
+ }
1039
+
1040
+ /**
1041
+ * Resolves the next standard loop event bypassing custom interruptions.
1042
+ * @param {number} walkerTime - The internal time cursor.
1043
+ * @param {number} originalTargetDate - The initial requested epoch target.
1044
+ * @returns {RadioEvent|null} The formatted base event.
1045
+ */
1046
+ #getNextBaseEvent(walkerTime, originalTargetDate) {
1047
+ let cycleInfo = this.#locateCycleForTime(walkerTime);
1048
+ if (!cycleInfo) return null;
1049
+
1050
+ const { block, startTimestamp } = cycleInfo;
1051
+ const cycleCurrentTime = walkerTime - startTimestamp;
1052
+
1053
+ /** @type {CycleBlockData|undefined} */
1054
+ let nextItem = block.items.find((i) => i.cycleEnd > cycleCurrentTime);
1055
+ let absoluteStart;
1056
+
1057
+ if (nextItem) {
1058
+ absoluteStart = startTimestamp + nextItem.cycleStart;
1059
+ } else {
1060
+ // Reached the gap between cycles, fetch the next loop
1061
+ cycleInfo = this.#locateCycleForTime(startTimestamp + block.duration);
1062
+ if (!cycleInfo || cycleInfo.block.items.length === 0) return null;
1063
+
1064
+ nextItem = cycleInfo.block.items[0];
1065
+ absoluteStart = cycleInfo.startTimestamp + nextItem.cycleStart;
1066
+ }
1067
+
1068
+ const qTime = Math.max(originalTargetDate, absoluteStart);
1069
+ return this.#formatEvent(nextItem, absoluteStart, qTime, false);
1070
+ }
1071
+
1072
+ /**
1073
+ * Calculates exactly what is playing at a specific absolute timestamp.
1074
+ * @param {number} absoluteTime - The target time to inspect.
1075
+ * @param {number} originalQueryTime - Original requested time to calculate elapsed data.
1076
+ * @returns {RadioEvent|null} The active event or null.
1077
+ */
1078
+ #getEventAtTime(absoluteTime, originalQueryTime) {
1079
+ const activeCustom = this.#customPositions.find(
1080
+ (cp) =>
1081
+ absoluteTime >= cp.intendedTimestamp &&
1082
+ absoluteTime < cp.intendedTimestamp + cp.content.duration,
1083
+ );
1084
+
1085
+ if (activeCustom) {
1086
+ return this.#formatEvent(
1087
+ activeCustom.content,
1088
+ activeCustom.intendedTimestamp,
1089
+ originalQueryTime,
1090
+ true,
1091
+ );
1092
+ }
1093
+
1094
+ const cycleInfo = this.#locateCycleForTime(absoluteTime);
1095
+ if (!cycleInfo) return null;
1096
+
1097
+ const { block, startTimestamp } = cycleInfo;
1098
+ const cycleRelativeTime = absoluteTime - startTimestamp;
1099
+
1100
+ const currentItem = block.items.find(
1101
+ (i) => cycleRelativeTime >= i.cycleStart && cycleRelativeTime < i.cycleEnd,
1102
+ );
1103
+
1104
+ if (!currentItem) return null;
1105
+ return this.#formatEvent(
1106
+ currentItem,
1107
+ startTimestamp + currentItem.cycleStart,
1108
+ originalQueryTime,
1109
+ false,
1110
+ );
1111
+ }
1112
+
1113
+ /**
1114
+ * Formats internal block data into standardized external event structures.
1115
+ * @param {CycleBlockData | MediaContent} item - Raw content data.
1116
+ * @param {number} absoluteStart - Event's absolute start epoch.
1117
+ * @param {number} queryTime - Timestamp requested for progress math.
1118
+ * @param {boolean} isCustom - Flag indicating if it is a user injected custom event.
1119
+ * @returns {RadioEvent} Standardized output.
1120
+ */
1121
+ #formatEvent(item, absoluteStart, queryTime, isCustom) {
1122
+ const elapsedTime = queryTime - absoluteStart;
1123
+ return {
1124
+ id: item.id,
1125
+ title: item.title,
1126
+ artist: item.artist,
1127
+ duration: item.duration,
1128
+ url: item.url,
1129
+ absoluteStart: absoluteStart,
1130
+ absoluteEnd: absoluteStart + item.duration,
1131
+ elapsedTime: elapsedTime,
1132
+ remainingTime: item.duration - elapsedTime,
1133
+ progress: Math.min(1, elapsedTime / item.duration),
1134
+ isCustom: isCustom,
1135
+ };
1136
+ }
1137
+
1138
+ /**
1139
+ * Safely calculates the best absolute timestamp gap for a custom event without disrupting metadata.
1140
+ * @param {MediaContent & { timestamp?: number }} data - Target data to insert.
1141
+ * @param {boolean} smartQueue - Adjusts the insertion to the end of the currently playing content.
1142
+ */
1143
+ #handleCustomInsertion(data, smartQueue) {
1144
+ let originalTarget = data.timestamp || Date.now();
1145
+ const duration = data.duration;
1146
+
1147
+ if (smartQueue) {
1148
+ // Check what is playing at the exact moment of the originalTarget
1149
+ const eventAtTime = this.#getEventAtTime(originalTarget, originalTarget);
1150
+
1151
+ // If there is any content playing (whether music, voice or even other custom),
1152
+ // we push the initial target to the exact millisecond where it ends.
1153
+ if (eventAtTime) {
1154
+ originalTarget = eventAtTime.absoluteEnd;
1155
+ }
1156
+ }
1157
+
1158
+ const activeCps = [...this.#customPositions].sort(
1159
+ (a, b) => a.intendedTimestamp - b.intendedTimestamp,
1160
+ );
1161
+
1162
+ let bestSlot = originalTarget;
1163
+ const hasOverlap = activeCps.some(
1164
+ (cp) =>
1165
+ originalTarget < cp.intendedTimestamp + cp.content.duration &&
1166
+ originalTarget + duration > cp.intendedTimestamp,
1167
+ );
1168
+
1169
+ if (hasOverlap) {
1170
+ const now = Date.now();
1171
+
1172
+ /**
1173
+ * Contains the valid windows of time available.
1174
+ * @type {Array<{start: number, end: number}>}
1175
+ */
1176
+ const gaps = [];
1177
+ let currentBoundary = now;
1178
+
1179
+ // Extract available timeline gaps
1180
+ for (const cp of activeCps) {
1181
+ if (cp.intendedTimestamp > currentBoundary) {
1182
+ gaps.push({ start: currentBoundary, end: cp.intendedTimestamp });
1183
+ }
1184
+ currentBoundary = Math.max(
1185
+ currentBoundary,
1186
+ cp.intendedTimestamp + cp.content.duration + this.#config.silenceDuration,
1187
+ );
1188
+ }
1189
+ gaps.push({ start: currentBoundary, end: Infinity });
1190
+
1191
+ // Mathematical closest distance algorithm
1192
+ let minDistance = Infinity;
1193
+
1194
+ for (const gap of gaps) {
1195
+ if (gap.end - gap.start >= duration) {
1196
+ let candidate = null;
1197
+
1198
+ if (originalTarget >= gap.start && originalTarget + duration <= gap.end) {
1199
+ candidate = originalTarget;
1200
+ } else if (originalTarget < gap.start) {
1201
+ candidate = gap.start;
1202
+ } else if (originalTarget > gap.end) {
1203
+ candidate = gap.end - duration;
1204
+ }
1205
+
1206
+ if (candidate !== null) {
1207
+ const distance = Math.abs(originalTarget - candidate);
1208
+ if (distance < minDistance) {
1209
+ minDistance = distance;
1210
+ bestSlot = candidate;
1211
+ }
1212
+ }
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ this.#customPositions.push({
1218
+ content: data,
1219
+ intendedTimestamp: bestSlot,
1220
+ originalTimestamp: data.timestamp || Date.now(),
1221
+ });
1222
+ }
1223
+
1224
+ /**
1225
+ * Processes all pending tasks up to the requested boundary time and shifts internal timelines.
1226
+ * @param {number} boundaryTime - Threshold to apply mutations.
1227
+ */
1228
+ #syncRealTimeState(boundaryTime) {
1229
+ const pendingTasks = this.#scheduledTasks.filter((t) => t.timestamp <= boundaryTime);
1230
+ this.#scheduledTasks = this.#scheduledTasks.filter((t) => t.timestamp > boundaryTime);
1231
+
1232
+ const expiredCps = this.#customPositions.filter(
1233
+ (cp) => cp.intendedTimestamp + cp.content.duration <= boundaryTime,
1234
+ );
1235
+ this.#customPositions = this.#customPositions.filter(
1236
+ (cp) => cp.intendedTimestamp + cp.content.duration > boundaryTime,
1237
+ );
1238
+
1239
+ let listsMutated = false;
1240
+
1241
+ expiredCps.forEach((cp) => {
1242
+ mediaContent.revokeContentUrls(cp.content); // Free memory!
1243
+ this.#seed += cp.content.id.length;
1244
+ listsMutated = true;
1245
+ this.emit('customPositionExpired', { contentId: cp.content.id });
1246
+ });
1247
+
1248
+ // Applies scheduled modifications intelligently, establishing new anchor epochs to prevent timeline corruption.
1249
+ if (pendingTasks.length > 0) {
1250
+ pendingTasks
1251
+ .sort((a, b) => a.timestamp - b.timestamp)
1252
+ .forEach((task) => {
1253
+ const list = task.type === 'music' ? this.#musicList : this.#voiceList;
1254
+
1255
+ if (task.action === 'add') {
1256
+ list.push(/** @type {MediaContent} */ (task.payload));
1257
+ } else if (task.action === 'remove') {
1258
+ const payloadId = /** @type {string} */ (task.payload);
1259
+ const idx = list.findIndex((i) => i.id === payloadId);
1260
+ if (idx !== -1) {
1261
+ const [removedItem] = list.splice(idx, 1);
1262
+ mediaContent.revokeContentUrls(removedItem); // Free memory!
1263
+ }
1264
+ } else if (task.action === 'move') {
1265
+ const payloadData = /** @type {ScheduledMovePayload} */ (task.payload);
1266
+ const idx = list.findIndex((i) => i.id === payloadData.id);
1267
+ if (idx !== -1) {
1268
+ const [item] = list.splice(idx, 1);
1269
+ list.splice(payloadData.newIndex, 0, item);
1270
+ }
1271
+ }
1272
+
1273
+ this.#anchorDate = task.timestamp;
1274
+ this.#seed += 1; // Adapt timeline
1275
+ listsMutated = true;
1276
+ this.emit('taskExecuted', structuredClone(task));
1277
+ });
1278
+ }
1279
+
1280
+ if (listsMutated) {
1281
+ this.#cycleCache.clear();
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Hydrates class state from an exported JSON object.
1287
+ * @param {TinyRadioFmImport} data
1288
+ */
1289
+ #hydrate(data) {
1290
+ // Revoke current blob URLs before overwriting state to prevent memory leaks
1291
+ this.#musicList.forEach((item) => mediaContent.revokeContentUrls(item));
1292
+ this.#voiceList.forEach((item) => mediaContent.revokeContentUrls(item));
1293
+ this.#customPositions.forEach((cp) => mediaContent.revokeContentUrls(cp.content));
1294
+ this.#scheduledTasks.forEach((task) => {
1295
+ if (
1296
+ task.action === 'add' &&
1297
+ task.payload &&
1298
+ typeof task.payload === 'object' &&
1299
+ 'title' in task.payload
1300
+ ) {
1301
+ mediaContent.revokeContentUrls(/** @type {MediaContent} */ (task.payload));
1302
+ }
1303
+ });
1304
+
1305
+ const processListForImport = (/** @type {MediaContent[]} */ list) => {
1306
+ return list.map((item) => {
1307
+ const newItem = { ...item };
1308
+ if (newItem.picture && Array.isArray(newItem.picture)) {
1309
+ newItem.picture = newItem.picture.map((pic) => ({
1310
+ ...pic,
1311
+ data: mediaContent.convertToBlobUrl(pic.data, pic.format),
1312
+ }));
1313
+ }
1314
+ return newItem;
1315
+ });
1316
+ };
1317
+
1318
+ this.#musicList = processListForImport(data.music);
1319
+ this.#voiceList = processListForImport(data.voice);
1320
+ this.#seed = data.seed || 0;
1321
+ this.#anchorDate = data.anchorDate || Date.now();
1322
+ this.#config = { ...this.#config, ...(data.config || {}) };
1323
+
1324
+ this.#customPositions = data.custom.map((cp) => ({
1325
+ ...cp,
1326
+ content: processListForImport([cp.content])[0],
1327
+ }));
1328
+
1329
+ this.#scheduledTasks = data.tasks.map((task) => {
1330
+ if (
1331
+ task.action === 'add' &&
1332
+ task.payload &&
1333
+ typeof task.payload === 'object' &&
1334
+ 'title' in task.payload
1335
+ ) {
1336
+ return {
1337
+ ...task,
1338
+ payload: processListForImport([/** @type {MediaContent} */ (task.payload)])[0],
1339
+ };
1340
+ }
1341
+ return task;
1342
+ });
1343
+ }
1344
+
1345
+ /**
1346
+ * Destroys the radio instance, releasing all allocated memory (including Blob URLs)
1347
+ * and permanently cleaning all caches, lists and tasks.
1348
+ * @param {boolean} [destroyThumbs=true]
1349
+ */
1350
+ destroy(destroyThumbs = true) {
1351
+ if (destroyThumbs) {
1352
+ this.#musicList.forEach((item) => mediaContent.revokeContentUrls(item));
1353
+ this.#voiceList.forEach((item) => mediaContent.revokeContentUrls(item));
1354
+ this.#customPositions.forEach((cp) => mediaContent.revokeContentUrls(cp.content));
1355
+ this.#scheduledTasks.forEach((task) => {
1356
+ if (
1357
+ task.action === 'add' &&
1358
+ task.payload &&
1359
+ typeof task.payload === 'object' &&
1360
+ 'title' in task.payload
1361
+ ) {
1362
+ mediaContent.revokeContentUrls(/** @type {MediaContent} */ (task.payload));
1363
+ }
1364
+ });
1365
+ }
1366
+
1367
+ this.#musicList = [];
1368
+ this.#voiceList = [];
1369
+ this.#customPositions = [];
1370
+ this.#scheduledTasks = [];
1371
+
1372
+ this.#cycleCache.clear();
1373
+ this.emit('destroyed', { timestamp: Date.now() });
1374
+ this.removeAllListeners();
1375
+ }
1376
+ }
1377
+
1378
+ module.exports = TinyRadioFm;