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