tiny-essentials 1.29.1 → 1.29.2

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 (128) hide show
  1. package/changelog/1/29/2.md +82 -0
  2. package/dist/v1/TinyBasicsEs.min.js +1 -1
  3. package/dist/v1/TinyEssentials.min.js +1 -1
  4. package/dist/v1/TinyFetchLoader.min.js +1 -0
  5. package/dist/v1/TinyImageLoader.min.js +1 -0
  6. package/dist/v1/TinyMediaCache.min.js +1 -0
  7. package/dist/v1/TinyMediaLoader.min.js +1 -0
  8. package/dist/v1/basics/index.cjs +5 -0
  9. package/dist/v1/basics/index.d.mts +2 -1
  10. package/dist/v1/basics/index.d.mts.map +1 -1
  11. package/dist/v1/basics/index.mjs +2 -1
  12. package/dist/v1/basics/media/youtube.cjs +275 -0
  13. package/dist/v1/basics/media/youtube.d.mts +107 -0
  14. package/dist/v1/basics/media/youtube.d.mts.map +1 -0
  15. package/dist/v1/basics/media/youtube.mjs +214 -0
  16. package/dist/v1/index.cjs +13 -0
  17. package/dist/v1/index.d.mts +6 -1
  18. package/dist/v1/index.d.mts.map +1 -1
  19. package/dist/v1/index.mjs +7 -2
  20. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.cjs +0 -1
  21. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.d.mts +0 -1
  22. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.d.mts.map +1 -1
  23. package/dist/v1/libs/fs/plugins/OPFS/StorageBucket.mjs +0 -1
  24. package/dist/v1/libs/fs/plugins/OPFS/index.cjs +0 -8
  25. package/dist/v1/libs/fs/plugins/OPFS/index.d.mts +0 -8
  26. package/dist/v1/libs/fs/plugins/OPFS/index.d.mts.map +1 -1
  27. package/dist/v1/libs/fs/plugins/OPFS/index.mjs +0 -8
  28. package/dist/v1/libs/html/TinyHtml.cjs +1 -1
  29. package/dist/v1/libs/html/TinyHtml.d.mts +2 -2
  30. package/dist/v1/libs/html/TinyHtml.d.mts.map +1 -1
  31. package/dist/v1/libs/html/TinyHtml.mjs +1 -1
  32. package/dist/v1/libs/html/loader/TinyMediaLoader.cjs +1999 -0
  33. package/dist/v1/libs/html/loader/TinyMediaLoader.d.mts +999 -0
  34. package/dist/v1/libs/html/loader/TinyMediaLoader.d.mts.map +1 -0
  35. package/dist/v1/libs/html/loader/TinyMediaLoader.mjs +1852 -0
  36. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.cjs +77 -0
  37. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.d.mts +103 -0
  38. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.d.mts.map +1 -0
  39. package/dist/v1/libs/html/loader/beta/formats/TinyAudioLoader.mjs +67 -0
  40. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.cjs +630 -0
  41. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.d.mts +138 -0
  42. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.d.mts.map +1 -0
  43. package/dist/v1/libs/html/loader/beta/formats/TinyContentLoader.mjs +576 -0
  44. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.cjs +70 -0
  45. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.d.mts +100 -0
  46. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.d.mts.map +1 -0
  47. package/dist/v1/libs/html/loader/beta/formats/TinyVideoLoader.mjs +60 -0
  48. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.cjs +946 -0
  49. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.d.mts +67 -0
  50. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.d.mts.map +1 -0
  51. package/dist/v1/libs/html/loader/beta/probe/TinyMediaProbe.mjs +896 -0
  52. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.cjs +225 -0
  53. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.d.mts +149 -0
  54. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.d.mts.map +1 -0
  55. package/dist/v1/libs/html/loader/formats/TinyFetchLoader.mjs +197 -0
  56. package/dist/v1/libs/html/loader/formats/TinyImageLoader.cjs +1454 -0
  57. package/dist/v1/libs/html/loader/formats/TinyImageLoader.d.mts +200 -0
  58. package/dist/v1/libs/html/loader/formats/TinyImageLoader.d.mts.map +1 -0
  59. package/dist/v1/libs/html/loader/formats/TinyImageLoader.mjs +1317 -0
  60. package/dist/v1/libs/html/loader/probe/TinyImageProbe.cjs +668 -0
  61. package/dist/v1/libs/html/loader/probe/TinyImageProbe.d.mts +83 -0
  62. package/dist/v1/libs/html/loader/probe/TinyImageProbe.d.mts.map +1 -0
  63. package/dist/v1/libs/html/loader/probe/TinyImageProbe.mjs +626 -0
  64. package/dist/v1/libs/html/loader/utils/TinyBufferReader.cjs +277 -0
  65. package/dist/v1/libs/html/loader/utils/TinyBufferReader.d.mts +118 -0
  66. package/dist/v1/libs/html/loader/utils/TinyBufferReader.d.mts.map +1 -0
  67. package/dist/v1/libs/html/loader/utils/TinyBufferReader.mjs +247 -0
  68. package/dist/v1/libs/html/loader/utils/TinyIsoBox.cjs +128 -0
  69. package/dist/v1/libs/html/loader/utils/TinyIsoBox.d.mts +49 -0
  70. package/dist/v1/libs/html/loader/utils/TinyIsoBox.d.mts.map +1 -0
  71. package/dist/v1/libs/html/loader/utils/TinyIsoBox.mjs +116 -0
  72. package/dist/v1/libs/html/loader/utils/TinyMediaCache.cjs +796 -0
  73. package/dist/v1/libs/html/loader/utils/TinyMediaCache.d.mts +354 -0
  74. package/dist/v1/libs/html/loader/utils/TinyMediaCache.d.mts.map +1 -0
  75. package/dist/v1/libs/html/loader/utils/TinyMediaCache.mjs +734 -0
  76. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.cjs +302 -0
  77. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.d.mts +256 -0
  78. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.d.mts.map +1 -0
  79. package/dist/v1/libs/html/loader/utils/TinyMediaMirror.mjs +280 -0
  80. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.cjs +194 -0
  81. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.d.mts +138 -0
  82. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.d.mts.map +1 -0
  83. package/dist/v1/libs/html/loader/utils/TinyMediaProgress.mjs +175 -0
  84. package/dist/v1/libs/tools/TinyPkgExportValidator.cjs +359 -55
  85. package/dist/v1/libs/tools/TinyPkgExportValidator.d.mts +64 -6
  86. package/dist/v1/libs/tools/TinyPkgExportValidator.d.mts.map +1 -1
  87. package/dist/v1/libs/tools/TinyPkgExportValidator.mjs +320 -57
  88. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.cjs +0 -1
  89. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.d.mts +0 -1
  90. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.d.mts.map +1 -1
  91. package/dist/v1/libs/worker/engine/TinyWebWorkerEngine.mjs +0 -1
  92. package/dist/v1/webTemplates/bootstrap/5.3/scss/background/_background-grid.scss +15 -0
  93. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/buttons/_buttons-contrast.scss +208 -0
  94. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/buttons/_buttons-ghost.scss +89 -0
  95. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/icon/_cube-icon.scss +65 -0
  96. package/dist/v1/webTemplates/bootstrap/5.3/scss/components/icon/_sphere-pulse.scss +39 -0
  97. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_badges.scss +90 -0
  98. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_bootstrap-overrides.scss +495 -0
  99. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_buttons.scss +390 -0
  100. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_colors.scss +213 -0
  101. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_effects.scss +153 -0
  102. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_forms-tones.scss +69 -0
  103. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_forms.scss +323 -0
  104. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_global.scss +31 -0
  105. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_links.scss +47 -0
  106. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_tables.scss +25 -0
  107. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_theme.scss +21 -0
  108. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_tokens.scss +161 -0
  109. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_utilities.scss +81 -0
  110. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/_variables.scss +63 -0
  111. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_form-tone-mixins.scss +236 -0
  112. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_sizing.scss +68 -0
  113. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_tone-mixins.scss +392 -0
  114. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/_typography.scss +112 -0
  115. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/background/_tiny_gradient_1.scss +131 -0
  116. package/dist/v1/webTemplates/bootstrap/5.3/scss/themes/deepstrap/plugins/colors/_pink.scss +47 -0
  117. package/docs/v1/Exports.md +33 -2
  118. package/docs/v1/README.md +9 -0
  119. package/docs/v1/basics/youtube.md +204 -0
  120. package/docs/v1/libs/html/loader/TinyFetchLoader.md +114 -0
  121. package/docs/v1/libs/html/loader/TinyImageLoader.md +141 -0
  122. package/docs/v1/libs/html/loader/TinyMediaCache.md +166 -0
  123. package/docs/v1/libs/html/loader/TinyMediaLoader.md +300 -0
  124. package/docs/v1/libs/tools/TinyPkgExportValidator.md +55 -0
  125. package/docs/v1/webTemplates/bootstrap/5.3/DeepStrap.md +177 -0
  126. package/package.json +244 -8
  127. /package/dist/v1/webTemplates/bootstrap/5.3/scss/input/{Placeholder-Colors-Fix.scss → _placeholder-colors-fix.scss} +0 -0
  128. /package/dist/v1/webTemplates/bootstrap/5.3/scss/spacing/{Spacing-Plus.scss → _spacing-plus.scss} +0 -0
@@ -0,0 +1,1852 @@
1
+ /**
2
+ * @fileoverview Base class for controllable media loading (images and videos).
3
+ * Provides a state machine, lifecycle events, cache detection and metadata
4
+ * extraction shared by every concrete media loader.
5
+ */
6
+ import { EventEmitter } from 'events';
7
+ /** @import { ICustomEventEmitter } from '../../../jsdoc/EventEmitter.mjs' */
8
+ import TinyMediaProgress from './utils/TinyMediaProgress.mjs';
9
+ import TinyMediaCache from './utils/TinyMediaCache.mjs';
10
+ import TinyMediaMirror from './utils/TinyMediaMirror.mjs';
11
+ /**
12
+ * @template {string} Name
13
+ * @template {unknown[]} Args
14
+ * @typedef {import('./utils/TinyMediaMirror.mjs').MediaMirrorEntry<Name, Args>} MediaMirrorEntry
15
+ */
16
+ /**
17
+ * @template {string} Name
18
+ * @template {unknown[]} Args
19
+ * @typedef {import('./utils/TinyMediaMirror.mjs').MediaMirrorEvent<Name, Args>} MediaMirrorEvent
20
+ */
21
+ /**
22
+ * Describes the configuration object accepted by the {@link TinyMediaLoader} constructor.
23
+ * Every property is optional, so a loader can be created empty and configured later.
24
+ * @template {HTMLImageElement|HTMLVideoElement|HTMLAudioElement} HTMLMainElement
25
+ * @typedef {Object} MediaLoaderOptions
26
+ * @property {string} [src] - The media source URL.
27
+ * @property {number} [timeout] - Maximum time in milliseconds before the load is aborted.
28
+ * @property {HTMLMainElement|null} [element] - An existing DOM element to adopt.
29
+ * @property {boolean} [autoReload] - Whether an external `src` change triggers a reload.
30
+ * @property {boolean} [originalSrc] - Whether the original URL is mirrored into the `original-src` attribute.
31
+ * @property {boolean} [earlyDecode] - Whether to decode image dimensions from the response headers using WebCodecs.
32
+ * @property {boolean} [progressive] - Whether to render progressively during fetch (default true).
33
+ * @property {boolean} [useHeadRequest] - Whether to perform a HEAD request to fetch HTTP headers before downloading.
34
+ * @property {boolean} [mirror] - Whether concurrent loads of the same source share a single download (default true).
35
+ * @property {TinyMediaCache|null} [cache] - An optional shared memory cache.
36
+ * @property {BodyInit} [body] - The request body forwarded to the transport.
37
+ */
38
+ /**
39
+ * Describes the payload emitted whenever the source URL changes, whether the change
40
+ * originated inside the loader or from an external DOM mutation.
41
+ * @typedef {Object} MediaSrcChangePayload
42
+ * @property {string} src - The new source URL.
43
+ * @property {string} previousSrc - The previous source URL.
44
+ */
45
+ /**
46
+ * Describes the payload emitted when the element swaps the network source for
47
+ * an internal object URL (or the reverse) during a streamed load.
48
+ * @typedef {Object} MediaSrcTransitionPayload
49
+ * @property {string} src - The original network source URL.
50
+ * @property {string|null} internalSrc - The internal object URL, or null when it is cleared.
51
+ */
52
+ /**
53
+ * Describes the immutable snapshot of everything known about a finished load.
54
+ * @typedef {Object} MediaMetadata
55
+ * @property {string} src - The resolved source URL.
56
+ * @property {string} type - The media MIME type (e.g., "image/png").
57
+ * @property {number} width - The intrinsic width in pixels.
58
+ * @property {number} height - The intrinsic height in pixels.
59
+ * @property {number} duration - The duration in seconds (0 for images).
60
+ * @property {number} size - The decoded size in bytes (0 when unknown).
61
+ * @property {boolean} fromCache - Whether the media was served from the cache.
62
+ * @property {number} loadTime - The total load time in milliseconds.
63
+ * @property {number} timestamp - The epoch timestamp when the load finished.
64
+ * @property {import('./probe/TinyImageProbe.mjs').AnimationProbeData | null} animation - The animation data.
65
+ * @property {Record<string, string>} [httpHeaders] - All HTTP response headers captured during the fetch.
66
+ */
67
+ /**
68
+ * Describes the payload emitted when a new chunk is downloaded.
69
+ * @typedef {Object} MediaChunkPayload
70
+ * @property {Uint8Array} chunk - The binary chunk.
71
+ * @property {number} loaded - The total bytes downloaded so far.
72
+ * @property {number} total - The total bytes of the media (0 when unknown).
73
+ */
74
+ /**
75
+ * Describes the payload emitted when a loader attaches to an in-flight download.
76
+ * @typedef {Object} MediaMirrorPayload
77
+ * @property {string} src - The source URL shared by both loaders.
78
+ * @property {string} key - The normalized registry key.
79
+ * @property {number} size - The size of the shared payload in bytes.
80
+ * @property {number} mirrors - The number of loaders attached to the download.
81
+ * @property {number} replayed - The number of historical events caught up on attach.
82
+ */
83
+ /**
84
+ * Describes the media specific values returned by the abstract `_getMetadataDetails` hook
85
+ * and merged into the final {@link MediaMetadata} object.
86
+ * @typedef {Object} MediaMetadataDetails
87
+ * @property {string} type - The media MIME type.
88
+ * @property {number} width - The intrinsic width in pixels.
89
+ * @property {number} height - The intrinsic height in pixels.
90
+ * @property {number} duration - The duration in seconds.
91
+ * @property {number} size - The decoded size in bytes.
92
+ */
93
+ /**
94
+ * @callback DecoderCreator
95
+ * Builds a decoder backed by the container header parser.
96
+ * @param {string} mime - The video MIME type.
97
+ * @returns {MediaEarlyDecoder} The decoder.
98
+ */
99
+ /**
100
+ * Handles a successful media load.
101
+ * @typedef {(metadata: MediaMetadata) => void} MediaLoadHandler
102
+ */
103
+ /**
104
+ * Handles a failed media load.
105
+ * @typedef {(error: Error) => void} MediaErrorHandler
106
+ */
107
+ /**
108
+ * Handles the start of a media load.
109
+ * @typedef {(payload: { src: string }) => void} MediaStartHandler
110
+ */
111
+ /**
112
+ * Handles a source URL change.
113
+ * @typedef {(payload: MediaSrcChangePayload) => void} MediaSrcChangeHandler
114
+ */
115
+ /**
116
+ * Handles an event that carries no payload.
117
+ * @typedef {() => void} MediaVoidHandler
118
+ */
119
+ /**
120
+ * Describes the payload emitted on every state machine transition.
121
+ * @typedef {Object} MediaStateChangePayload
122
+ * @property {string} state - The new state.
123
+ * @property {string} previous - The previous state.
124
+ * @property {string} src - The source URL the transition belongs to.
125
+ */
126
+ /**
127
+ * Describes the payload emitted when a load exceeds the configured timeout.
128
+ * @typedef {Object} MediaTimeoutPayload
129
+ * @property {string} src - The source URL that timed out.
130
+ * @property {number} timeout - The configured timeout in milliseconds.
131
+ */
132
+ /**
133
+ * Handle returned by the early decoder factory. It is a thin wrapper around the
134
+ * WebCodecs `ImageDecoder` so the loader can push chunks and release the
135
+ * decoder without knowing whether the API is available.
136
+ * @typedef {Object} MediaEarlyDecoder
137
+ * @property {(chunk: Uint8Array) => void} write - Pushes a chunk into the decoder.
138
+ * @property {() => void} close - Closes the decoder and releases its resources.
139
+ * @property {() => Partial<MediaMetadata>|null} [finalize] - Flushes the metadata that is only known at the end of the stream.
140
+ */
141
+ /**
142
+ * @typedef {'HEAD'|'GET'|'PROBE'|'DECODE'|'CACHE'|'LOADED_METADATA'|'DURATION_CHANGE'|'PROBE_ANIMATION'|'MIRROR'} MediaMetadataType
143
+ */
144
+ /**
145
+ * Describes every event emitted by {@link TinyMediaLoader}.
146
+ *
147
+ * The keys are the event names, and the values are the listener signatures.
148
+ * This is the shape expected by `ICustomEventEmitter<MediaLoaderEvents>`.
149
+ * @typedef {Object} MediaLoaderEvents
150
+ * @property {(payload: { src: string }) => void} loadstart - Emitted when a media load process begins.
151
+ * @property {(metadata: MediaMetadata) => void} loaded - Emitted when the media has been fully loaded and decoded.
152
+ * @property {(payload: { src: string, state: string }) => void} loadend - Emitted when the loading process finishes, regardless of success or failure.
153
+ * @property {(payload: { src: string }) => void} abort - Emitted when the current load operation is cancelled.
154
+ * @property {(error: Error) => void} error - Emitted when an unrecoverable error occurs during loading.
155
+ * @property {(payload: MediaTimeoutPayload) => void} timeout - Emitted when the load operation exceeds the configured timeout.
156
+ * @property {(payload: import('./utils/TinyMediaProgress.mjs').MediaProgressSnapshot) => void} progress - Emitted periodically to provide download progress updates.
157
+ * @property {(metadata: MediaMetadata, type: MediaMetadataType) => void} metadata - Emitted whenever media metadata is partially or fully resolved.
158
+ * @property {(response: Response) => void} response - Emitted when the main HTTP response for the media resource is received.
159
+ * @property {(response: Response) => void} headResponse - Emitted when the HTTP HEAD response is received.
160
+ * @property {(payload: MediaSrcChangePayload) => void} srcchange - Emitted when the source URL of the loader is changed.
161
+ * @property {(payload: MediaSrcTransitionPayload) => void} srctransition - Emitted when the element's source swaps between a network URL and an internal object URL.
162
+ * @property {(entry: import('./utils/TinyMediaCache.mjs').MediaCacheEntry) => void} cachemiss - Emitted when the resource is not found in the cache.
163
+ * @property {(entry: import('./utils/TinyMediaCache.mjs').MediaCacheEntry) => void} cachehit - Emitted when the resource is successfully retrieved from the cache.
164
+ * @property {(payload: MediaStateChangePayload) => void} statechange - Emitted when the loader transitions from one state to another.
165
+ * @property {(payload: MediaChunkPayload) => void} chunk - Emitted every time a binary data chunk is received during streaming.
166
+ * @property {(payload: MediaMirrorPayload) => void} mirror - Emitted when the download is shared with another loader.
167
+ * @property {() => void} destroy - Emitted when the loader is destroyed and its resources are released.
168
+ */
169
+ /** @type {new () => ICustomEventEmitter<MediaLoaderEvents>} */
170
+ const TypedEmitter = /** @type {any} */ (EventEmitter);
171
+ /**
172
+ * Base class that orchestrates the full lifecycle of a media element.
173
+ * It must be extended. Concrete subclasses implement `_createElement`,
174
+ * `_startLoad`, `_getMetadataDetails`, `_abort` and `_cleanup`.
175
+ * @abstract
176
+ * @template {HTMLImageElement|HTMLVideoElement|HTMLAudioElement} HTMLMainElement
177
+ * @augments TypedEmitter
178
+ */
179
+ class TinyMediaLoader extends TypedEmitter {
180
+ /**
181
+ * The registry that deduplicates concurrent downloads.
182
+ * @returns {typeof TinyMediaMirror} The shared mirror registry.
183
+ */
184
+ static get TinyMediaMirror() {
185
+ return TinyMediaMirror;
186
+ }
187
+ /**
188
+ * Immutable state machine values used by every media loader.
189
+ */
190
+ static MediaState = Object.freeze({
191
+ IDLE: 'idle',
192
+ LOADING: 'loading',
193
+ LOADED: 'loaded',
194
+ ERROR: 'error',
195
+ DESTROYED: 'destroyed',
196
+ });
197
+ /**
198
+ * The uppercase tag name of the element this loader manages.
199
+ * Subclasses must override it so the constructor can validate adopted elements.
200
+ * @returns {string} The uppercase tag name, or an empty string when the subclass does not override it.
201
+ */
202
+ static get tagName() {
203
+ return '';
204
+ }
205
+ /** @type {string} */
206
+ #src;
207
+ /** @type {string} */
208
+ #state = TinyMediaLoader.MediaState.IDLE;
209
+ /** @type {HTMLMainElement|null} */
210
+ #element = null;
211
+ /** @type {number} */
212
+ #timeout;
213
+ /** @type {number} */
214
+ #startTime = 0;
215
+ /** @type {number} */
216
+ #endTime = 0;
217
+ /** @type {boolean} */
218
+ #useHeadRequest = false;
219
+ /** @type {boolean} */
220
+ #cacheHint = false;
221
+ /** @type {boolean} */
222
+ #autoReload = false;
223
+ /** @type {MutationObserver|null} */
224
+ #observer = null;
225
+ /** @type {TinyMediaProgress|null} */
226
+ #progress = null;
227
+ /** @type {AbortController|null} */
228
+ #controller = null;
229
+ /** @type {Array<() => void>} */
230
+ #detachers = [];
231
+ /** @type {TinyMediaCache|null} */
232
+ #cache = null;
233
+ /** @type {boolean} */
234
+ #cacheAcquired = false;
235
+ /** @type {boolean} */
236
+ #aborted = false;
237
+ /** @type {string|null} */
238
+ #internalSrc = null;
239
+ /** @type {ReturnType<typeof setTimeout>|null} */
240
+ #timeoutId = null;
241
+ /** @type {string|null} */
242
+ #objectUrl = null;
243
+ /** @type {boolean} */
244
+ #originalSrc = false;
245
+ /** @type {number} */
246
+ #resourceSize = 0;
247
+ /** @type {boolean} */
248
+ #earlyDecode = false;
249
+ /** @type {MediaEarlyDecoder|null} */
250
+ #decoder = null;
251
+ /** @type {boolean} */
252
+ #progressive = true;
253
+ /** @type {Response|null} */
254
+ #response = null;
255
+ /** @type {Response|null} */
256
+ #headResponse = null;
257
+ /** @type {Map<string, DecoderCreator>} */
258
+ #decoders = new Map();
259
+ /** @type {boolean} */
260
+ #mirror = true;
261
+ /** @type {boolean} */
262
+ #mirrored = false;
263
+ /** @type {boolean} */
264
+ #replaying = false;
265
+ /** @type {((name: string, args: unknown[]) => void)|null} */
266
+ #publish = null;
267
+ /** @type {typeof fetch} */
268
+ #fetch = globalThis.fetch.bind(globalThis);
269
+ /** @type {RequestInit} */
270
+ #requestInit = {};
271
+ /** @type {BodyInit|undefined} */
272
+ #body = undefined;
273
+ /** @type {Partial<MediaMetadata>} */
274
+ #partial = {};
275
+ /** @type {MediaMetadata} */
276
+ #metadata = {
277
+ src: '',
278
+ type: '',
279
+ width: 0,
280
+ height: 0,
281
+ duration: 0,
282
+ size: 0,
283
+ fromCache: false,
284
+ loadTime: 0,
285
+ timestamp: 0,
286
+ animation: null,
287
+ };
288
+ /**
289
+ * @param {MediaLoaderOptions<HTMLMainElement>} [options] - The loader configuration.
290
+ * @throws {TypeError} If `options` is not a plain object.
291
+ * @throws {TypeError} If `options.src` is not a string.
292
+ * @throws {RangeError} If `options.timeout` is not a positive number.
293
+ * @throws {TypeError} If `options.element` is neither null nor an HTMLElement.
294
+ * @throws {TypeError} If `options.element` does not match the loader tag name.
295
+ */
296
+ constructor(options = {}) {
297
+ if (options === null || typeof options !== 'object') {
298
+ throw new TypeError('The "options" argument must be an object.');
299
+ }
300
+ const { src, timeout = Infinity, element = null, autoReload = false, originalSrc = false, earlyDecode = true, progressive = true, useHeadRequest = false, mirror = true, body, } = options;
301
+ if (src !== undefined && typeof src !== 'string') {
302
+ throw new TypeError('The "src" option must be a string.');
303
+ }
304
+ if (typeof timeout !== 'number' || Number.isNaN(timeout) || timeout < 0) {
305
+ throw new RangeError('The "timeout" option must be a positive number.');
306
+ }
307
+ if (element !== null && !(element instanceof HTMLElement)) {
308
+ throw new TypeError('The "element" option must be an HTMLElement or null.');
309
+ }
310
+ if (typeof originalSrc !== 'boolean') {
311
+ throw new TypeError('The "originalSrc" option must be a boolean.');
312
+ }
313
+ if (typeof earlyDecode !== 'boolean') {
314
+ throw new TypeError('The "earlyDecode" option must be a boolean.');
315
+ }
316
+ if (typeof progressive !== 'boolean') {
317
+ throw new TypeError('The "progressive" option must be a boolean.');
318
+ }
319
+ if (typeof mirror !== 'boolean') {
320
+ throw new TypeError('The "mirror" option must be a boolean.');
321
+ }
322
+ if (typeof autoReload !== 'boolean') {
323
+ throw new TypeError('The "autoReload" option must be a boolean.');
324
+ }
325
+ super();
326
+ const expectedTag = /** @type {typeof TinyMediaLoader} */ (this.constructor).tagName;
327
+ if (element && expectedTag && element.tagName !== expectedTag) {
328
+ throw new TypeError(`The "element" option must be a <${expectedTag.toLowerCase()}> element.`);
329
+ }
330
+ this.#src = src ?? element?.getAttribute('src') ?? '';
331
+ this.#timeout = timeout;
332
+ this.#autoReload = autoReload;
333
+ this.#originalSrc = originalSrc;
334
+ if (options.cache !== undefined &&
335
+ options.cache !== null &&
336
+ !(options.cache instanceof TinyMediaCache)) {
337
+ throw new TypeError('The "cache" option must be a TinyMediaCache or null.');
338
+ }
339
+ this.#cache = options.cache ?? null;
340
+ this.#resetLoadState();
341
+ this.#useHeadRequest = useHeadRequest;
342
+ this.#earlyDecode = earlyDecode;
343
+ this.#progressive = progressive;
344
+ this.#body = body;
345
+ this.#mirror = mirror;
346
+ if (element) {
347
+ this.#element = element;
348
+ this.#startObserver();
349
+ }
350
+ }
351
+ /**
352
+ * Events that are never broadcast to the mirror registry.
353
+ *
354
+ * - `mirror` would recurse forever.
355
+ * - `destroy` is scoped to a single instance and tears down the emitter.
356
+ * - `error` is excluded because a failed leader makes its mirrors fall back
357
+ * to their own request, which owns the error path from that point on.
358
+ * @type {ReadonlySet<string|symbol>}
359
+ */
360
+ static #UNMIRRORED = new Set(['mirror', 'destroy', 'error', 'metadata']);
361
+ /**
362
+ * Emits an event and, when this loader leads a mirrored download, broadcasts
363
+ * it to every attached mirror. The local listeners always run first so a slow
364
+ * mirror can never delay the loader that owns the network request.
365
+ * @template {keyof MediaLoaderEvents} K
366
+ * @param {K} name - The event name.
367
+ * @param {Parameters<MediaLoaderEvents[K]>} args - The event arguments.
368
+ * @returns {boolean} True when the event had listeners.
369
+ */
370
+ emit(name, ...args) {
371
+ const result = super.emit(name, ...args);
372
+ if (this.#publish && !this.#replaying && !TinyMediaLoader.#UNMIRRORED.has(name)) {
373
+ this.#publish(name, args);
374
+ }
375
+ return result;
376
+ }
377
+ /**
378
+ * Whether concurrent loads of the same source share a single download.
379
+ * @returns {boolean} True when mirroring is enabled.
380
+ */
381
+ get mirror() {
382
+ return this.#mirror;
383
+ }
384
+ /**
385
+ * Whether the last download was served by another loader.
386
+ * @returns {boolean} True when the payload was shared by a mirrored download.
387
+ */
388
+ get mirrored() {
389
+ return this.#mirrored;
390
+ }
391
+ /**
392
+ * The HTTP response received from the media resource.
393
+ * @returns {Response|null} The response object, or null if no response has been received yet.
394
+ */
395
+ get response() {
396
+ return this.#response;
397
+ }
398
+ /**
399
+ * The HTTP response received from the preliminary HEAD request.
400
+ * @returns {Response|null} The response object, or null if no HEAD request was performed or received.
401
+ */
402
+ get headResponse() {
403
+ return this.#headResponse;
404
+ }
405
+ /**
406
+ * Whether to perform a HEAD request to fetch HTTP headers before downloading.
407
+ * @returns {boolean}
408
+ */
409
+ get useHeadRequest() {
410
+ return this.#useHeadRequest;
411
+ }
412
+ /**
413
+ * The object URL created for a streamed blob, when one exists.
414
+ * @returns {string|null} The active object URL, or null when none was created.
415
+ */
416
+ get objectUrl() {
417
+ return this.#objectUrl;
418
+ }
419
+ /**
420
+ * The current source URL.
421
+ * @returns {string} The current source URL, or an empty string when unset.
422
+ */
423
+ get src() {
424
+ return this.#src;
425
+ }
426
+ /**
427
+ * The internal object URL currently assigned to the element.
428
+ * @returns {string|null} The object URL assigned internally, or null when the element uses the network source.
429
+ */
430
+ get internalSrc() {
431
+ return this.#internalSrc;
432
+ }
433
+ /**
434
+ * Whether the loader mirrors the original URL into the `original-src` attribute.
435
+ * @returns {boolean} True when the attribute is managed by this loader.
436
+ */
437
+ get originalSrc() {
438
+ return this.#originalSrc;
439
+ }
440
+ /**
441
+ * Whether the loader decodes the image dimensions from the response headers
442
+ * through the WebCodecs `ImageDecoder`.
443
+ * @returns {boolean} True when early decoding is enabled.
444
+ */
445
+ get earlyDecode() {
446
+ return this.#earlyDecode;
447
+ }
448
+ /**
449
+ * Whether the loader progressively updates the media element while downloading.
450
+ * @returns {boolean} True when progressive loading is enabled.
451
+ */
452
+ get progressive() {
453
+ return this.#progressive;
454
+ }
455
+ /**
456
+ * Updates the source URL and mirrors it into the DOM element.
457
+ * @param {string} value - The new source URL.
458
+ * @throws {TypeError} If `value` is not a string.
459
+ */
460
+ set src(value) {
461
+ if (typeof value !== 'string') {
462
+ throw new TypeError('The "src" value must be a string.');
463
+ }
464
+ if (!this.#applySrc(value)) {
465
+ return;
466
+ }
467
+ if (this.#element && this.#element.getAttribute('src') !== value) {
468
+ if (value) {
469
+ this.#element.setAttribute('src', value);
470
+ }
471
+ else {
472
+ this.#element.removeAttribute('src');
473
+ }
474
+ this._flushMutations();
475
+ }
476
+ }
477
+ /**
478
+ * Indicates whether the last load was cancelled through {@link TinyMediaLoader#abort}.
479
+ * @returns {boolean} True when the in-flight load was aborted.
480
+ */
481
+ get aborted() {
482
+ return this.#aborted;
483
+ }
484
+ /**
485
+ * Indicates whether this loader currently holds a reference on a shared cache entry.
486
+ * @returns {boolean} True while a cache reference is held.
487
+ */
488
+ get cacheAcquired() {
489
+ return this.#cacheAcquired;
490
+ }
491
+ /**
492
+ * Indicates whether an external `src` change triggers an automatic reload.
493
+ * @returns {boolean} True when automatic reloading is enabled.
494
+ */
495
+ get autoReload() {
496
+ return this.#autoReload;
497
+ }
498
+ /**
499
+ * Indicates whether the last load was served from the cache.
500
+ * @returns {boolean} True when the last load was a cache hit.
501
+ */
502
+ get cacheHint() {
503
+ return this.#cacheHint;
504
+ }
505
+ /**
506
+ * The epoch timestamp, in milliseconds, captured when the last load finished.
507
+ * @returns {number} The end timestamp, or 0 when no load has finished yet.
508
+ */
509
+ get endTime() {
510
+ return this.#endTime;
511
+ }
512
+ /**
513
+ * The high resolution timestamp captured when the last load started.
514
+ * @returns {number} The start timestamp, or 0 when no load has started yet.
515
+ */
516
+ get startTime() {
517
+ return this.#startTime;
518
+ }
519
+ /**
520
+ * The current lifecycle state.
521
+ * @returns {string} One of the {@link TinyMediaLoader.MediaState} values.
522
+ */
523
+ get state() {
524
+ return this.#state;
525
+ }
526
+ /**
527
+ * The underlying DOM element (available after construction of the subclass).
528
+ * @returns {HTMLMainElement|null} The managed element, or null before it is created.
529
+ */
530
+ get element() {
531
+ return this.#element;
532
+ }
533
+ /**
534
+ * A defensive copy of the last known metadata.
535
+ * @returns {MediaMetadata} A shallow copy that is safe for the caller to mutate.
536
+ */
537
+ get metadata() {
538
+ return { ...this.#metadata, httpHeaders: { ...this.#metadata.httpHeaders } };
539
+ }
540
+ /**
541
+ * The total size in bytes of the media downloaded through the streaming path.
542
+ * @returns {number} The number of bytes streamed, or `0` when the media was not streamed.
543
+ */
544
+ get resourceSize() {
545
+ return this.#resourceSize;
546
+ }
547
+ /**
548
+ * @returns {boolean} True while the media is loading.
549
+ */
550
+ get isLoading() {
551
+ return this.#state === TinyMediaLoader.MediaState.LOADING;
552
+ }
553
+ /**
554
+ * @returns {boolean} True when the media has finished loading.
555
+ */
556
+ get isLoaded() {
557
+ return this.#state === TinyMediaLoader.MediaState.LOADED;
558
+ }
559
+ /**
560
+ * @returns {boolean} True when the last load failed.
561
+ */
562
+ get hasError() {
563
+ return this.#state === TinyMediaLoader.MediaState.ERROR;
564
+ }
565
+ /**
566
+ * The active progress tracker, or null when the load has not started.
567
+ * @returns {TinyMediaProgress|null}
568
+ */
569
+ get progress() {
570
+ return this.#progress;
571
+ }
572
+ /**
573
+ * The shared memory cache, or null when disabled.
574
+ * @returns {TinyMediaCache|null}
575
+ */
576
+ get cache() {
577
+ return this.#cache;
578
+ }
579
+ /**
580
+ * @param {TinyMediaCache|null} value - The new cache instance.
581
+ * @throws {TypeError} If `value` is neither a TinyMediaCache nor null.
582
+ */
583
+ set cache(value) {
584
+ if (value !== null && !(value instanceof TinyMediaCache)) {
585
+ throw new TypeError('The "cache" value must be a TinyMediaCache or null.');
586
+ }
587
+ this.#cache = value;
588
+ }
589
+ /**
590
+ * The AbortController bound to the current load.
591
+ * @returns {AbortController}
592
+ */
593
+ get signal() {
594
+ if (!this.#controller) {
595
+ throw new Error('Signal does not exist.');
596
+ }
597
+ return this.#controller;
598
+ }
599
+ /**
600
+ * The request body forwarded to the transport. It is ignored by `GET` and
601
+ * `HEAD`, which the Fetch specification forbids from carrying a body.
602
+ * @returns {BodyInit|undefined}
603
+ */
604
+ get body() {
605
+ return this.#body;
606
+ }
607
+ /**
608
+ * @param {BodyInit|undefined} value - The request body.
609
+ */
610
+ set body(value) {
611
+ this.#body = value;
612
+ }
613
+ /**
614
+ * Whether another loader is already downloading the current source.
615
+ * @returns {boolean} True when a mirrored download is in flight.
616
+ */
617
+ get pending() {
618
+ return TinyMediaMirror.has(this.#mirrorKey(this.#src));
619
+ }
620
+ /**
621
+ * The fetch implementation used to download the media.
622
+ * Subclasses may override it to inject a custom transport.
623
+ * @returns {typeof globalThis.fetch} The fetch implementation.
624
+ */
625
+ get _fetch() {
626
+ return this.#fetch;
627
+ }
628
+ /**
629
+ * Replaces the fetch implementation used to download the media.
630
+ * @param {typeof globalThis.fetch} implementation - The fetch implementation.
631
+ * @throws {TypeError} If `implementation` is not a function.
632
+ */
633
+ set _fetch(implementation) {
634
+ if (typeof implementation !== 'function') {
635
+ throw new TypeError('The "fetch" value must be a function.');
636
+ }
637
+ this.#fetch = implementation;
638
+ }
639
+ /**
640
+ * Extra options forwarded to the fetch implementation.
641
+ * Subclasses may override it to send headers, credentials or a body.
642
+ * @returns {RequestInit} The fetch options.
643
+ */
644
+ get _requestInit() {
645
+ return this.#requestInit;
646
+ }
647
+ /**
648
+ * @param {RequestInit} params - The fetch options.
649
+ */
650
+ set _requestInit(params) {
651
+ this.#requestInit = params;
652
+ }
653
+ /**
654
+ * Checks whether a URL already has a live payload held by the shared memory
655
+ * cache, without acquiring a reference and without touching the network.
656
+ *
657
+ * The lookup is strictly read-only: it never increments the `hits`/`misses`
658
+ * counters, never revives an expired entry and never creates an object URL.
659
+ * Use it to decide *whether* to call {@link TinyMediaLoader#load}, never as a
660
+ * replacement for it.
661
+ * @param {string} [src] - The URL to test. Defaults to the current source.
662
+ * @returns {boolean} True when the cache holds a live entry for the URL.
663
+ * @throws {TypeError} If `src` is not a string.
664
+ * @throws {Error} If the underlying cache was destroyed.
665
+ */
666
+ isCached(src = this.#src) {
667
+ if (typeof src !== 'string') {
668
+ throw new TypeError('The "src" argument must be a string.');
669
+ }
670
+ if (!this.#cache) {
671
+ return false;
672
+ }
673
+ return this.#cache.has(src);
674
+ }
675
+ /**
676
+ * Whether the current source is already available in the shared memory cache.
677
+ * @returns {boolean} True when the current source can load without a network request.
678
+ */
679
+ get cached() {
680
+ return this.isCached();
681
+ }
682
+ /**
683
+ * Reads the cache entry of a URL without acquiring a reference.
684
+ * @param {string} [src] - The URL to inspect. Defaults to the current source.
685
+ * @returns {import('./utils/TinyMediaCache.mjs').MediaCacheEntry|null} The cached entry, or null.
686
+ * @throws {TypeError} If `src` is not a string.
687
+ * @throws {Error} If the underlying cache was destroyed.
688
+ */
689
+ peekCache(src = this.#src) {
690
+ if (typeof src !== 'string') {
691
+ throw new TypeError('The "src" argument must be a string.');
692
+ }
693
+ if (!this.#cache) {
694
+ return null;
695
+ }
696
+ return this.#cache.peek(src);
697
+ }
698
+ /**
699
+ * Registers a decoder factory for a specific MIME type.
700
+ * This allows the loader to instantiate the correct decoder for different media formats.
701
+ * @protected
702
+ * @param {string} mime - The MIME type (or prefix) that this decoder handles.
703
+ * @param {DecoderCreator} callback - The factory function used to create the decoder.
704
+ * @returns {void}
705
+ */
706
+ _insertDecoder(mime, callback) {
707
+ if (typeof mime !== 'string' || mime.length === 0) {
708
+ throw new TypeError('The "mime" argument must be a non-empty string.');
709
+ }
710
+ if (typeof callback !== 'function') {
711
+ throw new TypeError('The "callback" argument must be a function.');
712
+ }
713
+ this.#decoders.set(mime, callback);
714
+ }
715
+ /**
716
+ * Creates a fresh progress tracker for the current load.
717
+ * @protected
718
+ * @param {number} [total] - The total number of bytes (0 when unknown).
719
+ * @returns {TinyMediaProgress} The freshly created and already started progress tracker.
720
+ * @throws {TypeError} If `total` is not a finite number.
721
+ * @throws {RangeError} If `total` is negative.
722
+ */
723
+ _createProgress(total = 0) {
724
+ if (typeof total !== 'number' || !Number.isFinite(total)) {
725
+ throw new TypeError('The "total" argument must be a finite number.');
726
+ }
727
+ if (total < 0) {
728
+ throw new RangeError('The "total" argument must be a non-negative number.');
729
+ }
730
+ this.#progress = new TinyMediaProgress(total).start();
731
+ return this.#progress;
732
+ }
733
+ /**
734
+ * Emits a progress snapshot when a tracker is active.
735
+ * @protected
736
+ * @returns {void}
737
+ */
738
+ _emitProgress() {
739
+ if (this.#progress) {
740
+ this.emit('progress', this.#progress.snapshot());
741
+ }
742
+ }
743
+ /**
744
+ * Merges a partial metadata patch into the current snapshot and notifies the
745
+ * listeners. It is called as soon as the response headers arrive, so a
746
+ * consumer can render the file type and size before the download finishes.
747
+ * Keys whose value is `undefined` are ignored, so a decoder that cannot
748
+ * resolve a dimension never clobbers a value that was already known.
749
+ * @protected
750
+ * @param {Partial<MediaMetadata>} patch - The values to merge.
751
+ * @param {MediaMetadataType} type - The action type.
752
+ * @returns {void}
753
+ * @throws {TypeError} If `patch` is not a plain object.
754
+ */
755
+ _emitMetadata(patch, type) {
756
+ if (patch === null || typeof patch !== 'object') {
757
+ throw new TypeError('The "patch" argument must be an object.');
758
+ }
759
+ if (typeof type !== 'string' || type.length === 0) {
760
+ throw new TypeError('The "type" argument must be a non-empty string.');
761
+ }
762
+ const clean = /** @type {Partial<MediaMetadata>} */ (Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)));
763
+ this.#partial = { ...this.#partial, ...clean };
764
+ this.#metadata = { ...this.#metadata, ...clean };
765
+ this.#publish?.('metadata', [clean, type]);
766
+ this.emit('metadata', this.metadata, type);
767
+ }
768
+ /**
769
+ * Registers a DOM listener that is removed on the next load or on destroy.
770
+ * @protected
771
+ * @param {HTMLMainElement} element - The source element.
772
+ * @param {string} type - The DOM event name.
773
+ * @param {EventListener} handler - The listener to register.
774
+ * @returns {void}
775
+ * @throws {TypeError} If any argument has an invalid type.
776
+ */
777
+ _on(element, type, handler) {
778
+ if (!(element instanceof HTMLElement)) {
779
+ throw new TypeError('The "element" argument must be an HTMLElement.');
780
+ }
781
+ if (typeof type !== 'string' || type.length === 0) {
782
+ throw new TypeError('The "type" argument must be a non-empty string.');
783
+ }
784
+ if (typeof handler !== 'function') {
785
+ throw new TypeError('The "handler" argument must be a function.');
786
+ }
787
+ element.addEventListener(type, handler);
788
+ this.#detachers.push(() => element.removeEventListener(type, handler));
789
+ }
790
+ /**
791
+ * Forwards DOM events from an element to this emitter.
792
+ * @protected
793
+ * @param {HTMLMainElement} element - The source element.
794
+ * @param {string[]} events - The DOM event names to forward.
795
+ * @returns {void}
796
+ * @throws {TypeError} If `events` is not an array of non-empty strings.
797
+ */
798
+ _forwardEvents(element, events) {
799
+ if (!Array.isArray(events)) {
800
+ throw new TypeError('The "events" argument must be an array of strings.');
801
+ }
802
+ for (const name of events) {
803
+ if (typeof name !== 'string' || name.length === 0) {
804
+ throw new TypeError('Every entry of "events" must be a non-empty string.');
805
+ }
806
+ this._on(element, name, (event) => {
807
+ /** @type {any} */
808
+ // @ts-ignore
809
+ this.emit(name, event);
810
+ });
811
+ }
812
+ }
813
+ /**
814
+ * Removes every forwarded DOM listener.
815
+ * @protected
816
+ * @returns {void}
817
+ */
818
+ _detachEvents() {
819
+ for (const off of this.#detachers) {
820
+ off();
821
+ }
822
+ this.#detachers = [];
823
+ }
824
+ /**
825
+ * Starts loading the media and resolves with the final metadata.
826
+ * @returns {Promise<MediaMetadata>} A promise that resolves with the final metadata of the loaded media.
827
+ * @throws {Error} If the loader was destroyed or has no source.
828
+ */
829
+ async load() {
830
+ if (this.#state === TinyMediaLoader.MediaState.DESTROYED) {
831
+ throw new Error('Cannot load a destroyed media loader.');
832
+ }
833
+ if (this.#state === TinyMediaLoader.MediaState.LOADING) {
834
+ throw new Error('The media is already loading.');
835
+ }
836
+ if (!this.#src) {
837
+ throw new Error('Cannot load media without a source.');
838
+ }
839
+ this._detachEvents();
840
+ this._releaseFromCache();
841
+ this.#resetLoadState();
842
+ this.#closeDecoder();
843
+ this.#createController();
844
+ this._createProgress(0);
845
+ this.#setState(TinyMediaLoader.MediaState.LOADING);
846
+ this.emit('loadstart', { src: this.#src });
847
+ try {
848
+ await this.#withTimeout(this._startLoad());
849
+ this.#endTime = performance.now();
850
+ this.#metadata = this.#buildMetadata();
851
+ this.#setState(TinyMediaLoader.MediaState.LOADED);
852
+ this.emit('loaded', this.#metadata);
853
+ return this.#metadata;
854
+ }
855
+ catch (error) {
856
+ this.#endTime = performance.now();
857
+ if (this.#aborted) {
858
+ this.#aborted = false;
859
+ this.#setState(TinyMediaLoader.MediaState.IDLE);
860
+ this.emit('abort', { src: this.#src });
861
+ }
862
+ else {
863
+ this.#setState(TinyMediaLoader.MediaState.ERROR);
864
+ this.#emitError(/** @type {Error} */ (error));
865
+ }
866
+ throw error;
867
+ }
868
+ finally {
869
+ this.emit('loadend', { src: this.#src, state: this.#state });
870
+ }
871
+ }
872
+ /**
873
+ * Resets the state machine and loads the current source again.
874
+ * @returns {Promise<MediaMetadata>} A promise that resolves with the metadata of the new load.
875
+ * @throws {Error} If the loader is loading or was destroyed.
876
+ */
877
+ async reload() {
878
+ if (this.#state === TinyMediaLoader.MediaState.LOADING) {
879
+ throw new Error('Cannot reload while the media is loading.');
880
+ }
881
+ if (this.#state === TinyMediaLoader.MediaState.DESTROYED) {
882
+ throw new Error('Cannot reload a destroyed media loader.');
883
+ }
884
+ this.#progress?.reset();
885
+ this.#state = TinyMediaLoader.MediaState.IDLE;
886
+ return this.load();
887
+ }
888
+ /**
889
+ * Cancels the in-flight load, if any.
890
+ * @returns {boolean} True when a load was cancelled.
891
+ */
892
+ abort() {
893
+ if (this.#state !== TinyMediaLoader.MediaState.LOADING) {
894
+ return false;
895
+ }
896
+ this.#aborted = true;
897
+ if (this.#controller) {
898
+ this.#controller.abort();
899
+ }
900
+ return true;
901
+ }
902
+ /**
903
+ * Appends the media element to a container.
904
+ * @param {HTMLMainElement} container - The parent element.
905
+ * @returns {TinyMediaLoader<HTMLMainElement>} The current instance for chaining.
906
+ * @throws {TypeError} If `container` is not an HTMLElement.
907
+ * @throws {Error} If the element has not been created yet.
908
+ */
909
+ mount(container) {
910
+ if (!(container instanceof HTMLElement)) {
911
+ throw new TypeError('The "container" argument must be an HTMLElement.');
912
+ }
913
+ if (!this.#element) {
914
+ throw new Error('Cannot mount before the media element is created.');
915
+ }
916
+ container.appendChild(this.#element);
917
+ return this;
918
+ }
919
+ /**
920
+ * Releases every resource and detaches all listeners.
921
+ * @returns {void}
922
+ */
923
+ destroy() {
924
+ if (this.#state === TinyMediaLoader.MediaState.DESTROYED) {
925
+ return;
926
+ }
927
+ this.#state = TinyMediaLoader.MediaState.DESTROYED;
928
+ if (this.#controller) {
929
+ this.#controller.abort();
930
+ }
931
+ this._cleanup();
932
+ this._detachEvents();
933
+ this.#stopObserver();
934
+ if (this.#timeoutId !== null) {
935
+ clearTimeout(this.#timeoutId);
936
+ this.#timeoutId = null;
937
+ }
938
+ this.#closeDecoder();
939
+ this._releaseFromCache();
940
+ this.#decoders.clear();
941
+ this.emit('destroy');
942
+ this.removeAllListeners();
943
+ this.#element = null;
944
+ }
945
+ /**
946
+ * Returns the adopted element, creating a new one when none was provided.
947
+ * Subclasses must call this inside `_startLoad`.
948
+ * @protected
949
+ * @returns {HTMLMainElement}
950
+ */
951
+ _resolveElement() {
952
+ if (!this.#element) {
953
+ this.#element = this._createElement();
954
+ }
955
+ this.#startObserver();
956
+ return this.#element;
957
+ }
958
+ /**
959
+ * Registers a concrete DOM element. Kept for advanced subclasses.
960
+ * @protected
961
+ * @param {HTMLMainElement} element - The element to expose through `element`.
962
+ * @returns {void}
963
+ * @throws {TypeError} If `element` is not an HTMLElement.
964
+ */
965
+ _setElement(element) {
966
+ if (!(element instanceof HTMLElement)) {
967
+ throw new TypeError('The "element" argument must be an HTMLElement.');
968
+ }
969
+ this.#element = element;
970
+ this.#startObserver();
971
+ }
972
+ /**
973
+ * Flags that the media was served from the cache before the network layer ran.
974
+ * @protected
975
+ * @param {boolean} value - The cache hint.
976
+ * @returns {void}
977
+ * @throws {TypeError} If `value` is not a boolean.
978
+ */
979
+ _setCacheHint(value) {
980
+ if (typeof value !== 'boolean') {
981
+ throw new TypeError('The "value" argument must be a boolean.');
982
+ }
983
+ this.#cacheHint = Boolean(value);
984
+ }
985
+ /**
986
+ * Records the internal object URL assigned to the element so the mutation
987
+ * observer treats the swap as an internal transition instead of an external
988
+ * source change. It also mirrors the network URL into the `original-src`
989
+ * attribute while an object URL is active.
990
+ * @protected
991
+ * @param {string|null} url - The object URL assigned internally, or null to clear it.
992
+ * @returns {void}
993
+ * @throws {TypeError} If `url` is neither a string nor null.
994
+ */
995
+ _setInternalSrc(url) {
996
+ if (url !== null && typeof url !== 'string') {
997
+ throw new TypeError('The "url" argument must be a string or null.');
998
+ }
999
+ this.#internalSrc = url;
1000
+ this.#syncOriginalSrc();
1001
+ this._flushMutations();
1002
+ this.emit('srctransition', { src: this.#src, internalSrc: url });
1003
+ }
1004
+ /**
1005
+ * Mirrors the original network URL into the `original-src` attribute while an
1006
+ * object URL is active, and removes it otherwise. The attribute is skipped
1007
+ * when the loader was created with `originalSrc: false`.
1008
+ * @returns {void}
1009
+ */
1010
+ #syncOriginalSrc() {
1011
+ const element = this.#element;
1012
+ if (!element || !this.#originalSrc) {
1013
+ return;
1014
+ }
1015
+ if (this.#internalSrc) {
1016
+ element.setAttribute('original-src', this.#src);
1017
+ }
1018
+ else {
1019
+ element.removeAttribute('original-src');
1020
+ }
1021
+ }
1022
+ /**
1023
+ * Waits for a media element to become ready while staying bound to the
1024
+ * current abort signal. The returned promise rejects as soon as the loader is
1025
+ * aborted, which prevents the caller from awaiting a download that will never
1026
+ * finish.
1027
+ * @protected
1028
+ * @param {HTMLMainElement} element - The media element being loaded.
1029
+ * @param {string} [readyEvent] - The DOM event that signals a successful load.
1030
+ * @returns {Promise<void>} Resolves once the media is ready.
1031
+ * @throws {TypeError} If `element` is not an HTMLElement.
1032
+ * @throws {TypeError} If `readyEvent` is not a string.
1033
+ */
1034
+ _waitForMedia(element, readyEvent = 'loadeddata') {
1035
+ if (!(element instanceof HTMLElement)) {
1036
+ throw new TypeError('The "element" argument must be an HTMLElement.');
1037
+ }
1038
+ if (typeof readyEvent !== 'string') {
1039
+ throw new TypeError('The "readyEvent" argument must be a string.');
1040
+ }
1041
+ if (!this.#controller) {
1042
+ throw new Error('Signal does not exist to wait for media.');
1043
+ }
1044
+ const { signal } = this.#controller;
1045
+ return new Promise((resolve, reject) => {
1046
+ const onReady = () => {
1047
+ cleanup();
1048
+ resolve(undefined);
1049
+ };
1050
+ const onError = () => {
1051
+ cleanup();
1052
+ reject(new Error(`Failed to load media: ${this.#src}`));
1053
+ };
1054
+ const onAbort = () => {
1055
+ cleanup();
1056
+ reject(new DOMException('The media load was aborted.', 'AbortError'));
1057
+ };
1058
+ const cleanup = () => {
1059
+ element.removeEventListener(readyEvent, onReady);
1060
+ element.removeEventListener('error', onError);
1061
+ signal.removeEventListener('abort', onAbort);
1062
+ };
1063
+ element.addEventListener(readyEvent, onReady);
1064
+ element.addEventListener('error', onError);
1065
+ signal.addEventListener('abort', onAbort, { once: true });
1066
+ });
1067
+ }
1068
+ /**
1069
+ * Stores a downloaded blob in the shared cache when one is configured and
1070
+ * returns the URL the element must render. When no cache is present the blob
1071
+ * is exposed through a private object URL instead. An entry that is already
1072
+ * cached is reused, because replacing it would revoke the object URL that
1073
+ * another loader may still be rendering.
1074
+ * @protected
1075
+ * @param {Blob} blob - The downloaded blob.
1076
+ * @returns {string} The URL the element must use as its source.
1077
+ * @throws {TypeError} If `blob` is not a Blob.
1078
+ */
1079
+ _cacheBlob(blob) {
1080
+ if (!(blob instanceof Blob)) {
1081
+ throw new TypeError('The "blob" argument must be a Blob.');
1082
+ }
1083
+ this._setResourceSize(blob.size);
1084
+ if (this.#cache) {
1085
+ const existing = this.#cache.get(this.#src);
1086
+ if (existing) {
1087
+ this.#cache.acquire(this.#src);
1088
+ this.#cacheAcquired = true;
1089
+ return existing.objectUrl;
1090
+ }
1091
+ const entry = this.#cache.set(this.#src, blob, false, this.#partial);
1092
+ this.#cache.acquire(this.#src);
1093
+ this.#cacheAcquired = true;
1094
+ this.emit('cachemiss', entry);
1095
+ return entry.objectUrl;
1096
+ }
1097
+ return this._createObjectUrl(blob);
1098
+ }
1099
+ /**
1100
+ * Downloads a media resource through fetch. When another loader is already
1101
+ * downloading the same URL, the request is not repeated: the promise resolves
1102
+ * with the very same {@link Blob} instance that the leading loader receives.
1103
+ * @protected
1104
+ * @template {keyof MediaLoaderEvents} K
1105
+ * @param {string} url - The resource to download.
1106
+ * @param {(chunk: BlobPart, chunks: BlobPart[]) => void} [onChunk] - Optional chunk callback.
1107
+ * @param {number} [startByte=0] - The byte offset to start downloading from.
1108
+ * @param {AbortSignal|null} [customSignal=null] - Signal to abort only the fetch without aborting the loader.
1109
+ * @returns {Promise<Blob>} The fully downloaded blob.
1110
+ * @throws {TypeError} If `url` is not a string.
1111
+ */
1112
+ async _downloadStream(url, onChunk, startByte = 0, customSignal = null) {
1113
+ if (typeof url !== 'string') {
1114
+ throw new TypeError('The "url" argument must be a string.');
1115
+ }
1116
+ if (!Number.isInteger(startByte) || startByte < 0) {
1117
+ throw new RangeError('The "startByte" argument must be a non-negative integer.');
1118
+ }
1119
+ if (onChunk !== undefined && typeof onChunk !== 'function') {
1120
+ throw new TypeError('The "onChunk" argument must be a function or undefined.');
1121
+ }
1122
+ const key = this.#mirrorKey(url);
1123
+ const shareable = this.#mirror && startByte === 0;
1124
+ /** @type {MediaMirrorEntry<keyof MediaLoaderEvents, Parameters<MediaLoaderEvents[K]>>|null} */
1125
+ // @ts-ignore
1126
+ const shared = shareable ? TinyMediaMirror.attach(key) : null;
1127
+ if (shared) {
1128
+ return this.#mirrorStream(shared, url, onChunk, startByte, customSignal);
1129
+ }
1130
+ const entry = shareable ? TinyMediaMirror.open(key, url) : null;
1131
+ this.#publish = entry ? (name, args) => TinyMediaMirror.publish(key, name, args) : null;
1132
+ try {
1133
+ const blob = await this.#download(url, onChunk, startByte, customSignal);
1134
+ entry?.resolve(blob);
1135
+ return blob;
1136
+ }
1137
+ catch (error) {
1138
+ entry?.reject(/** @type {Error} */ (error));
1139
+ throw error;
1140
+ }
1141
+ finally {
1142
+ this.#publish = null;
1143
+ if (entry) {
1144
+ TinyMediaMirror.close(key);
1145
+ }
1146
+ }
1147
+ }
1148
+ /**
1149
+ * Performs the actual network download.
1150
+ * @param {string} url - The resource to download.
1151
+ * @param {(chunk: BlobPart, chunks: BlobPart[]) => void} [onChunk] - Optional chunk callback.
1152
+ * @param {number} [startByte=0] - The byte offset to start downloading from.
1153
+ * @param {AbortSignal|null} [customSignal=null] - Signal to abort only the fetch without aborting the loader.
1154
+ * @returns {Promise<Blob>} The fully downloaded blob.
1155
+ * @throws {Error} If the request fails or the body is not readable.
1156
+ */
1157
+ async #download(url, onChunk, startByte = 0, customSignal = null) {
1158
+ if (typeof url !== 'string') {
1159
+ throw new TypeError('The "url" argument must be a string.');
1160
+ }
1161
+ const method = this.#requestInit.method?.toUpperCase();
1162
+ const canRange = method === 'GET';
1163
+ const canHead = this.#useHeadRequest && method === 'GET' && startByte === 0;
1164
+ let type = '';
1165
+ let actualTotal = 0;
1166
+ let headSuccess = false;
1167
+ // 1. Optional: HEAD Fetch before GET to capture metadata quickly
1168
+ if (canHead) {
1169
+ try {
1170
+ const headResponse = await this.#fetch(url, {
1171
+ ...this.#requestInit,
1172
+ method: 'HEAD',
1173
+ signal: customSignal || this.signal.signal,
1174
+ });
1175
+ this.#headResponse = headResponse;
1176
+ this.emit('headResponse', headResponse);
1177
+ if (headResponse.ok) {
1178
+ type = headResponse.headers.get('content-type') || '';
1179
+ const cl = headResponse.headers.get('content-length');
1180
+ if (cl)
1181
+ actualTotal = Number(cl);
1182
+ // Capture all headers
1183
+ /** @type {Record<string, string>} */
1184
+ const headHeaders = {};
1185
+ headResponse.headers.forEach((val, key) => {
1186
+ headHeaders[key] = val;
1187
+ });
1188
+ const patch = {};
1189
+ if (type)
1190
+ patch.type = type;
1191
+ if (actualTotal > 0)
1192
+ patch.size = actualTotal;
1193
+ if (Object.keys(headHeaders).length > 0)
1194
+ patch.httpHeaders = headHeaders;
1195
+ if (Object.keys(patch).length > 0) {
1196
+ this._emitMetadata(patch, 'HEAD');
1197
+ }
1198
+ headSuccess = true;
1199
+ }
1200
+ }
1201
+ catch (e) {
1202
+ // Silent Head Failure (may be CORS restricted to HEAD only). Go to the GET.
1203
+ }
1204
+ }
1205
+ const headers = new Headers(this.#requestInit.headers);
1206
+ if (canRange && startByte > 0) {
1207
+ headers.set('Range', `bytes=${startByte}-`);
1208
+ }
1209
+ // 2. Fetch real
1210
+ /** @type {RequestInit} */
1211
+ const init = {
1212
+ ...this.#requestInit,
1213
+ signal: customSignal || this.signal.signal,
1214
+ headers,
1215
+ };
1216
+ if (!init.body && this.#body !== undefined) {
1217
+ init.body = this.#body;
1218
+ }
1219
+ const response = await this.#fetch(url, init);
1220
+ this.#response = response;
1221
+ this.emit('response', response);
1222
+ if (!response.ok) {
1223
+ // If the server returns 416, the range is invalid
1224
+ if (response.status === 416)
1225
+ throw new Error('Requested range not satisfiable.');
1226
+ throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`);
1227
+ }
1228
+ if (!response.body) {
1229
+ return new Blob([], { type: type || 'application/octet-stream' });
1230
+ }
1231
+ const getType = response.headers.get('content-type') || type;
1232
+ const total = Number(response.headers.get('content-length') || 0);
1233
+ // Calculation adjustment for Status 206
1234
+ if (response.status === 206) {
1235
+ const contentRange = response.headers.get('content-range');
1236
+ if (contentRange) {
1237
+ const match = contentRange.match(/\/(\d+)$/);
1238
+ if (match)
1239
+ actualTotal = Number(match[1]);
1240
+ }
1241
+ }
1242
+ else {
1243
+ actualTotal = total > 0 ? total + startByte : actualTotal;
1244
+ }
1245
+ // Capture all headers
1246
+ /** @type {Record<string, string>} */
1247
+ const getHeaders = {};
1248
+ response.headers.forEach((val, key) => {
1249
+ getHeaders[key] = val;
1250
+ });
1251
+ /** @type {Partial<MediaMetadata>} */
1252
+ const patch = {};
1253
+ if (getType && getType !== type) {
1254
+ patch.type = getType;
1255
+ type = getType;
1256
+ }
1257
+ // If the HEAD request didn't run or if the GET request is a Range, we re-emit the size
1258
+ if (actualTotal > 0 && (!headSuccess || response.status === 206))
1259
+ patch.size = actualTotal;
1260
+ if (Object.keys(getHeaders).length > 0)
1261
+ patch.httpHeaders = getHeaders;
1262
+ if (Object.keys(patch).length > 0)
1263
+ this._emitMetadata(patch, 'GET');
1264
+ this.#openDecoder(type); // The earlyDecode (Probe) still respects its flag separately!
1265
+ const progress = this._createProgress(actualTotal);
1266
+ const reader = response.body.getReader();
1267
+ /** @type {BlobPart[]} */
1268
+ const chunks = [];
1269
+ // For the calculation of progress and the final Blob, we need to know what we already had here
1270
+ let currentTotalLoaded = startByte;
1271
+ try {
1272
+ for (;;) {
1273
+ const { done, value } = await reader.read();
1274
+ if (done)
1275
+ break;
1276
+ chunks.push(value);
1277
+ this.#decoder?.write(value);
1278
+ currentTotalLoaded += value.byteLength;
1279
+ progress.push(value.byteLength);
1280
+ // Emit the raw chunk for external libraries
1281
+ this.emit('chunk', {
1282
+ chunk: value,
1283
+ loaded: currentTotalLoaded,
1284
+ total: actualTotal,
1285
+ });
1286
+ this._emitProgress();
1287
+ if (onChunk)
1288
+ onChunk(value, chunks);
1289
+ }
1290
+ }
1291
+ finally {
1292
+ this.#closeDecoder();
1293
+ }
1294
+ // The final Blob should contain the bytes we already had (if any) + the new ones
1295
+ // But to simplify the cache, here we treat the blob like the new piece.
1296
+ // In production, you would concatenate the Blobs or use a File object.
1297
+ return new Blob(chunks, { type: type || 'application/octet-stream' });
1298
+ }
1299
+ /**
1300
+ * Builds the registry key used to deduplicate concurrent downloads. It mirrors
1301
+ * the normalization applied by the shared cache so both layers agree on what
1302
+ * "the same resource" means.
1303
+ * @param {string} url - The media URL.
1304
+ * @returns {string} The normalized registry key.
1305
+ */
1306
+ #mirrorKey(url) {
1307
+ if (!this.#cache?.ignoreSearch) {
1308
+ return url;
1309
+ }
1310
+ const clean = url.split('#')[0];
1311
+ const query = clean.indexOf('?');
1312
+ return query === -1 ? clean : clean.slice(0, query);
1313
+ }
1314
+ /**
1315
+ * Resolves a download that is already in flight on another loader. The full
1316
+ * event history is replayed before the first live event arrives, so a mirror
1317
+ * that attaches halfway through observes exactly the same sequence as a
1318
+ * listener attached to the leader from the very first byte.
1319
+ * @template {keyof MediaLoaderEvents} K
1320
+ * @param {MediaMirrorEntry<keyof MediaLoaderEvents, Parameters<MediaLoaderEvents[K]>>} entry - The shared download.
1321
+ * @param {string} url - The resource being downloaded.
1322
+ * @param {((chunk: BlobPart, chunks: BlobPart[]) => void)|undefined} onChunk - The chunk callback.
1323
+ * @param {number} startByte - The byte offset of the request.
1324
+ * @param {AbortSignal|null} customSignal - The signal that aborts the request.
1325
+ * @returns {Promise<Blob>} The blob downloaded by the leading loader.
1326
+ */
1327
+ async #mirrorStream(entry, url, onChunk, startByte, customSignal) {
1328
+ /** @type {BlobPart[]} */
1329
+ const chunks = [];
1330
+ const progress = this._createProgress(0);
1331
+ const replayed = TinyMediaMirror.history(entry.key).length;
1332
+ const unsubscribe = TinyMediaMirror.subscribe(entry.key, (event) => {
1333
+ this.#applyEvent(event, chunks, progress, onChunk);
1334
+ });
1335
+ let blob;
1336
+ try {
1337
+ blob = await entry.promise;
1338
+ }
1339
+ catch {
1340
+ if (unsubscribe) {
1341
+ unsubscribe();
1342
+ }
1343
+ return this.#download(url, onChunk, startByte, customSignal);
1344
+ }
1345
+ if (unsubscribe) {
1346
+ unsubscribe();
1347
+ }
1348
+ this.#mirrored = true;
1349
+ this._setResourceSize(blob.size);
1350
+ if (blob.type) {
1351
+ this._emitMetadata({ type: blob.type }, 'MIRROR');
1352
+ }
1353
+ await this.#probeBlob(blob);
1354
+ this.emit('mirror', {
1355
+ src: this.#src,
1356
+ key: entry.key,
1357
+ size: blob.size,
1358
+ mirrors: entry.mirrors,
1359
+ replayed,
1360
+ });
1361
+ return blob;
1362
+ }
1363
+ /**
1364
+ * Applies a single mirrored event to this instance. Every event is applied in
1365
+ * the exact order it was published, whether it comes from the historical
1366
+ * catch-up or from the live stream. The event is type-erased because the
1367
+ * mirror registry stores the name and the arguments as plain data.
1368
+ * @param {MediaMirrorEvent<string, unknown[]>} event - The event to apply.
1369
+ * @param {BlobPart[]} chunks - The accumulator that rebuilds the payload.
1370
+ * @param {TinyMediaProgress} progress - The tracker advanced by every replayed chunk.
1371
+ * @param {((chunk: BlobPart, chunks: BlobPart[]) => void)|undefined} onChunk - The chunk callback.
1372
+ * @returns {void}
1373
+ */
1374
+ #applyEvent(event, chunks, progress, onChunk) {
1375
+ if (event.name === 'metadata') {
1376
+ const [patch, type] = /** @type {[Partial<MediaMetadata>, MediaMetadataType]} */ (event.args);
1377
+ this._emitMetadata(patch, type);
1378
+ return;
1379
+ }
1380
+ if (event.name === 'chunk') {
1381
+ const [payload] = /** @type {[MediaChunkPayload]} */ (event.args);
1382
+ const chunk = /** @type {BlobPart} */ (payload.chunk);
1383
+ if (progress.total === 0 && payload.total > 0) {
1384
+ progress.total = payload.total;
1385
+ }
1386
+ chunks.push(chunk);
1387
+ progress.push(payload.chunk.byteLength);
1388
+ if (onChunk) {
1389
+ onChunk(chunk, chunks);
1390
+ }
1391
+ }
1392
+ this.#replay(event.name, event.args);
1393
+ }
1394
+ /**
1395
+ * Re-emits a mirrored event without broadcasting it again. `Reflect.apply` is
1396
+ * used because the event name is only known at runtime: the generic `emit`
1397
+ * signature cannot be recovered from a `string`, so the call is dispatched
1398
+ * dynamically while `#replaying` keeps the event out of the mirror registry.
1399
+ * @param {string} name - The event name.
1400
+ * @param {unknown[]} args - The original event arguments.
1401
+ * @returns {void}
1402
+ */
1403
+ #replay(name, args) {
1404
+ this.#replaying = true;
1405
+ try {
1406
+ Reflect.apply(this.emit, this, [name, ...args]);
1407
+ }
1408
+ finally {
1409
+ this.#replaying = false;
1410
+ }
1411
+ }
1412
+ /**
1413
+ * Replays a shared blob through the early decoder so a mirrored download
1414
+ * exposes exactly the same metadata as a local one.
1415
+ * @param {Blob} blob - The shared payload.
1416
+ * @returns {Promise<void>} Resolves once the probe consumed the header.
1417
+ */
1418
+ async #probeBlob(blob) {
1419
+ this.#openDecoder(blob.type);
1420
+ if (!this.#decoder) {
1421
+ return;
1422
+ }
1423
+ this.#decoder.write(new Uint8Array(await blob.arrayBuffer()));
1424
+ this.#closeDecoder();
1425
+ }
1426
+ /**
1427
+ * Creates the header decoder used to read the intrinsic dimensions before the
1428
+ * download finishes. Images go through the WebCodecs `ImageDecoder`; videos
1429
+ * are parsed from the container header by {@link TinyMediaProbe}. It is a
1430
+ * no-op when early decoding is disabled or the MIME type is not supported.
1431
+ * Failures are swallowed on purpose: early decoding is best-effort.
1432
+ * @param {string} type - The MIME type from the response headers.
1433
+ * @returns {void}
1434
+ */
1435
+ #openDecoder(type) {
1436
+ if (!this.#earlyDecode)
1437
+ return;
1438
+ const mime = type.split(';')[0].trim().toLowerCase();
1439
+ this.#decoders.forEach((decoder, key) => {
1440
+ if (!this.#decoder && mime.startsWith(`${key}/`)) {
1441
+ this.#decoder = decoder(mime);
1442
+ }
1443
+ });
1444
+ }
1445
+ /**
1446
+ * Releases the early decoder, if one was created. When the decoder exposes a
1447
+ * `finalize` hook, it runs first so the metadata that is only known at the
1448
+ * end of the stream (for example the frame count of an animated GIF) is
1449
+ * emitted before the decoder is torn down.
1450
+ * @returns {void}
1451
+ */
1452
+ #closeDecoder() {
1453
+ if (!this.#decoder) {
1454
+ return;
1455
+ }
1456
+ if (typeof this.#decoder.finalize === 'function') {
1457
+ const final = this.#decoder.finalize();
1458
+ if (final) {
1459
+ this._emitMetadata(final, 'DECODE');
1460
+ }
1461
+ }
1462
+ this.#decoder.close();
1463
+ this.#decoder = null;
1464
+ }
1465
+ /**
1466
+ * Acquires the current source from the shared cache and remembers the
1467
+ * reference. A successful acquisition flags the metadata so the final
1468
+ * snapshot reports `fromCache: true` and restores the values that the
1469
+ * download path would have recorded, since no network request runs here.
1470
+ * @protected
1471
+ * @returns {import('./utils/TinyMediaCache.mjs').MediaCacheEntry|null}
1472
+ */
1473
+ _acquireFromCache() {
1474
+ if (!this.#cache) {
1475
+ return null;
1476
+ }
1477
+ const entry = this.#cache.acquire(this.#src);
1478
+ if (entry) {
1479
+ this.#cacheAcquired = true;
1480
+ this._setCacheHint(true);
1481
+ this._setResourceSize(entry.size);
1482
+ if (entry.type) {
1483
+ this._emitMetadata({ type: entry.type }, 'CACHE');
1484
+ }
1485
+ if (entry.metadata) {
1486
+ this._emitMetadata(entry.metadata, 'CACHE');
1487
+ }
1488
+ }
1489
+ return entry;
1490
+ }
1491
+ /**
1492
+ * Releases the reference held on the shared cache entry, when present.
1493
+ * @protected
1494
+ * @returns {void}
1495
+ */
1496
+ _releaseFromCache() {
1497
+ if (this.#cache && this.#cacheAcquired) {
1498
+ this.#cache.release(this.#src);
1499
+ this.#cacheAcquired = false;
1500
+ }
1501
+ }
1502
+ /**
1503
+ * The total time spent loading, in milliseconds.
1504
+ * @protected
1505
+ * @returns {number}
1506
+ */
1507
+ _getLoadTime() {
1508
+ return this.#endTime - this.#startTime;
1509
+ }
1510
+ /**
1511
+ * Uses the Resource Timing API to detect a cache hit.
1512
+ * @protected
1513
+ * @returns {boolean}
1514
+ */
1515
+ _detectCache() {
1516
+ if (typeof performance === 'undefined' || typeof performance.getEntriesByName !== 'function') {
1517
+ return false;
1518
+ }
1519
+ const entries = performance.getEntriesByName(this.#src);
1520
+ if (entries.length === 0) {
1521
+ return false;
1522
+ }
1523
+ const entry = /** @type {PerformanceResourceTiming} */ (entries[entries.length - 1]);
1524
+ return entry.transferSize === 0 && entry.decodedBodySize > 0;
1525
+ }
1526
+ /**
1527
+ * Records the exact byte size of the media when it is known without the
1528
+ * Resource Timing API, for example right after a streamed download.
1529
+ * @protected
1530
+ * @param {number} size - The size in bytes.
1531
+ * @returns {void}
1532
+ * @throws {RangeError} If `size` is not a positive number.
1533
+ */
1534
+ _setResourceSize(size) {
1535
+ if (typeof size !== 'number' || Number.isNaN(size) || size < 0) {
1536
+ throw new RangeError('The "size" argument must be a positive number.');
1537
+ }
1538
+ this.#resourceSize = size;
1539
+ }
1540
+ /**
1541
+ * Reads the decoded size of the resource. It prefers the size captured from a
1542
+ * streamed blob and falls back to the Resource Timing API, whose entries are
1543
+ * keyed by absolute URL.
1544
+ * @protected
1545
+ * @returns {number} The size in bytes, or 0 when unavailable.
1546
+ */
1547
+ _getResourceSize() {
1548
+ if (this.#resourceSize > 0) {
1549
+ return this.#resourceSize;
1550
+ }
1551
+ if (typeof performance === 'undefined' || typeof performance.getEntriesByName !== 'function') {
1552
+ return 0;
1553
+ }
1554
+ const entries = performance.getEntriesByName(this.#src);
1555
+ if (entries.length === 0) {
1556
+ return 0;
1557
+ }
1558
+ const entry = /** @type {PerformanceResourceTiming} */ (entries[entries.length - 1]);
1559
+ return entry.decodedBodySize || entry.transferSize || 0;
1560
+ }
1561
+ /**
1562
+ * Discards every pending mutation record.
1563
+ * Subclasses must call this right after mutating the element internally,
1564
+ * otherwise the observer reports a false external change.
1565
+ * @protected
1566
+ * @returns {void}
1567
+ */
1568
+ _flushMutations() {
1569
+ if (this.#observer) {
1570
+ this.#observer.takeRecords();
1571
+ }
1572
+ }
1573
+ /**
1574
+ * Creates an object URL for a blob and revokes the previous one.
1575
+ * @protected
1576
+ * @param {Blob} blob - The blob to expose through an object URL.
1577
+ * @returns {string} The newly created object URL.
1578
+ * @throws {TypeError} If `blob` is not a Blob.
1579
+ */
1580
+ _createObjectUrl(blob) {
1581
+ if (!(blob instanceof Blob)) {
1582
+ throw new TypeError('The "blob" argument must be a Blob.');
1583
+ }
1584
+ this._revokeObjectUrl();
1585
+ this.#objectUrl = URL.createObjectURL(blob);
1586
+ return this.#objectUrl;
1587
+ }
1588
+ /**
1589
+ * Revokes the current object URL, if any.
1590
+ * @protected
1591
+ * @returns {void}
1592
+ */
1593
+ _revokeObjectUrl() {
1594
+ if (this.#objectUrl) {
1595
+ URL.revokeObjectURL(this.#objectUrl);
1596
+ this.#objectUrl = null;
1597
+ }
1598
+ }
1599
+ /**
1600
+ * Creates a fresh AbortController for the current load and links its signal to
1601
+ * the coupled element, so aborting the signal stops the native download.
1602
+ * @returns {void}
1603
+ */
1604
+ #createController() {
1605
+ this.#controller = new AbortController();
1606
+ this.#controller.signal.addEventListener('abort', () => this._abort(), { once: true });
1607
+ }
1608
+ /**
1609
+ * Builds a fresh metadata object for the current source. Every field is
1610
+ * explicit so a new property can never be forgotten here.
1611
+ * @returns {MediaMetadata} A zeroed metadata snapshot.
1612
+ */
1613
+ #createMetadata() {
1614
+ return {
1615
+ src: this.#src,
1616
+ type: '',
1617
+ width: 0,
1618
+ height: 0,
1619
+ duration: 0,
1620
+ size: 0,
1621
+ fromCache: false,
1622
+ loadTime: 0,
1623
+ timestamp: 0,
1624
+ animation: null,
1625
+ httpHeaders: {},
1626
+ };
1627
+ }
1628
+ /**
1629
+ * Resets every field that is scoped to a single load. It is called at the
1630
+ * very beginning of {@link TinyMediaLoader#load} so that a failed, aborted or
1631
+ * cached load can never leak its state into the next one.
1632
+ * @returns {void}
1633
+ */
1634
+ #resetLoadState() {
1635
+ this.#aborted = false;
1636
+ this.#internalSrc = null;
1637
+ this.#resourceSize = 0;
1638
+ this.#cacheHint = false;
1639
+ this.#mirrored = false;
1640
+ this.#replaying = false;
1641
+ this.#publish = null;
1642
+ this.#partial = {};
1643
+ this.#startTime = performance.now();
1644
+ this.#endTime = 0;
1645
+ this.#metadata = this.#createMetadata();
1646
+ }
1647
+ /**
1648
+ * Applies a state transition and notifies the listeners.
1649
+ * @param {string} next - The next state.
1650
+ * @returns {void}
1651
+ */
1652
+ #setState(next) {
1653
+ if (this.#state === next) {
1654
+ return;
1655
+ }
1656
+ const previous = this.#state;
1657
+ this.#state = next;
1658
+ this.emit('statechange', { state: next, previous, src: this.#src });
1659
+ }
1660
+ /**
1661
+ * Emits an `error` event without crashing when nobody is listening.
1662
+ * @param {Error} error - The error to report.
1663
+ * @returns {void}
1664
+ */
1665
+ #emitError(error) {
1666
+ if (this.listenerCount('error') > 0) {
1667
+ this.emit('error', error);
1668
+ }
1669
+ }
1670
+ /**
1671
+ * Applies a new source value and emits `srcchange` when it differs.
1672
+ * @param {string} value - The candidate source URL.
1673
+ * @returns {boolean} True when the value actually changed.
1674
+ */
1675
+ #applySrc(value) {
1676
+ if (value === this.#src) {
1677
+ return false;
1678
+ }
1679
+ const previousSrc = this.#src;
1680
+ this.#src = value;
1681
+ this.#metadata.src = value;
1682
+ this.emit('srcchange', { src: value, previousSrc });
1683
+ return true;
1684
+ }
1685
+ /**
1686
+ * Starts observing the element for external `src` and `original-src`
1687
+ * mutations. The `original-src` attribute is only observed when the loader
1688
+ * was created with `originalSrc: true`, so a page that never opts into the
1689
+ * feature pays no cost for it.
1690
+ * @returns {void}
1691
+ */
1692
+ #startObserver() {
1693
+ if (this.#observer || typeof MutationObserver === 'undefined' || !this.#element) {
1694
+ return;
1695
+ }
1696
+ this.#observer = new MutationObserver((records) => this.#handleExternalSrcChange(records));
1697
+ this.#observer.observe(this.#element, {
1698
+ attributes: true,
1699
+ attributeFilter: this.#originalSrc ? ['src', 'original-src'] : ['src'],
1700
+ });
1701
+ }
1702
+ /**
1703
+ * Stops observing the element and drops every queued mutation.
1704
+ * @returns {void}
1705
+ */
1706
+ #stopObserver() {
1707
+ if (this.#observer) {
1708
+ this.#observer.disconnect();
1709
+ this.#observer = null;
1710
+ }
1711
+ }
1712
+ /**
1713
+ * Reacts to a `src` or `original-src` attribute change that did not come
1714
+ * from this class. When both attributes change in the same batch,
1715
+ * `original-src` wins because it carries the network URL, while `src` may
1716
+ * hold an object URL that this class created itself.
1717
+ * @param {MutationRecord[]} records - The records delivered by the observer.
1718
+ * @returns {void}
1719
+ */
1720
+ #handleExternalSrcChange(records) {
1721
+ const element = this.#element;
1722
+ if (!element) {
1723
+ return;
1724
+ }
1725
+ let next = null;
1726
+ for (const { attributeName } of records) {
1727
+ if (attributeName === 'original-src') {
1728
+ next = element.getAttribute('original-src') ?? '';
1729
+ }
1730
+ else if (attributeName === 'src' && next === null) {
1731
+ next = element.getAttribute('src') ?? '';
1732
+ }
1733
+ }
1734
+ if (next === null || next === this.#src || next === this.#internalSrc) {
1735
+ return;
1736
+ }
1737
+ this.#applySrc(next);
1738
+ if (this.#autoReload) {
1739
+ this.reload().catch((error) => {
1740
+ if (this.listenerCount('error') > 0) {
1741
+ this.emit('error', error);
1742
+ }
1743
+ });
1744
+ }
1745
+ }
1746
+ /**
1747
+ * Builds the final metadata object.
1748
+ * @returns {MediaMetadata}
1749
+ */
1750
+ #buildMetadata() {
1751
+ return {
1752
+ src: this.#src,
1753
+ fromCache: this.#cacheHint || this._detectCache(),
1754
+ loadTime: this.#endTime - this.#startTime,
1755
+ timestamp: Date.now(),
1756
+ animation: null,
1757
+ ...this._getMetadataDetails(),
1758
+ ...this.#partial,
1759
+ };
1760
+ }
1761
+ /**
1762
+ * Wraps a promise with a timeout. If the promise does not resolve within the
1763
+ * configured timeout, the current load operation is aborted and the promise rejects.
1764
+ * @param {Promise<*>} promise - The asynchronous operation to be guarded by the timeout.
1765
+ * @returns {Promise<*>} A promise that resolves with the original result or rejects on timeout.
1766
+ */
1767
+ #withTimeout(promise) {
1768
+ if (this.#timeout <= 0) {
1769
+ return promise;
1770
+ }
1771
+ return new Promise((resolve, reject) => {
1772
+ this.#timeoutId = setTimeout(() => {
1773
+ this.#timeoutId = null;
1774
+ if (this.#controller) {
1775
+ this.#controller.abort();
1776
+ }
1777
+ this.emit('timeout', { src: this.#src, timeout: this.#timeout });
1778
+ reject(new Error(`Media load timed out after ${this.#timeout}ms.`));
1779
+ }, this.#timeout);
1780
+ promise.then((value) => {
1781
+ if (this.#timeoutId !== null)
1782
+ clearTimeout(this.#timeoutId);
1783
+ this.#timeoutId = null;
1784
+ resolve(value);
1785
+ }, (error) => {
1786
+ if (this.#timeoutId !== null)
1787
+ clearTimeout(this.#timeoutId);
1788
+ this.#timeoutId = null;
1789
+ reject(error);
1790
+ });
1791
+ });
1792
+ }
1793
+ /**
1794
+ * Creates a brand new element. Must be implemented by subclasses.
1795
+ * @abstract
1796
+ * @protected
1797
+ * @returns {HTMLMainElement}
1798
+ */
1799
+ _createElement() {
1800
+ throw new Error('The "_createElement" method must be implemented by a subclass.');
1801
+ }
1802
+ /**
1803
+ * Performs the actual load. Must be implemented by subclasses.
1804
+ * @abstract
1805
+ * @protected
1806
+ * @returns {Promise<void>}
1807
+ */
1808
+ _startLoad() {
1809
+ throw new Error('The "_startLoad" method must be implemented by a subclass.');
1810
+ }
1811
+ /**
1812
+ * Returns the media-specific metadata. Must be implemented by subclasses.
1813
+ * @abstract
1814
+ * @protected
1815
+ * @returns {MediaMetadataDetails} The media specific metadata details.
1816
+ */
1817
+ _getMetadataDetails() {
1818
+ return { type: '', width: 0, height: 0, duration: 0, size: 0 };
1819
+ }
1820
+ /**
1821
+ * Aborts the network activity of the coupled element. It works for `<img>`,
1822
+ * `<audio>` and `<video>`: all three expose `removeAttribute`, while only the
1823
+ * media elements expose `pause` and `load`. Subclasses that hold extra
1824
+ * resources must override this method and call `super._abort()`.
1825
+ * @protected
1826
+ * @returns {void}
1827
+ */
1828
+ _abort() {
1829
+ this._revokeObjectUrl();
1830
+ const element = this.#element;
1831
+ if (!element) {
1832
+ return;
1833
+ }
1834
+ const media = /** @type {Partial<HTMLMediaElement>} */ (element);
1835
+ if (typeof media.pause === 'function') {
1836
+ media.pause();
1837
+ }
1838
+ element.removeAttribute('src');
1839
+ if (typeof media.load === 'function') {
1840
+ media.load();
1841
+ }
1842
+ this._setInternalSrc(null);
1843
+ }
1844
+ /**
1845
+ * Releases any listener or timer created by the subclass.
1846
+ * @abstract
1847
+ * @protected
1848
+ * @returns {void}
1849
+ */
1850
+ _cleanup() { }
1851
+ }
1852
+ export default TinyMediaLoader;