ngx-kit 7.3.3 → 7.4.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.
@@ -0,0 +1,1847 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, InjectionToken, inject, Injectable, Optional, Inject, NgZone, DestroyRef, computed, input, output, DOCUMENT, viewChild, effect, ChangeDetectionStrategy, Component, makeEnvironmentProviders } from '@angular/core';
3
+ import { CommonModule } from '@angular/common';
4
+
5
+ /**
6
+ * Format time in seconds as MM:SS
7
+ * @param seconds number
8
+ * @returns MM:SS
9
+ */
10
+ function formatTime(seconds) {
11
+ if (Number.isNaN(+seconds) || seconds == Infinity) {
12
+ seconds = 0; // return 'Unknown';
13
+ }
14
+ const minutes = Math.floor(seconds / 60);
15
+ const remainingSeconds = Math.floor(seconds % 60);
16
+ return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
17
+ }
18
+
19
+ /**
20
+ * @deprecated Use {@link NgxMediaSource} instead. Kept so existing `PlayList[]`
21
+ * consumers keep compiling; `fileAddress`/`title` map 1:1 onto `src`/`title`.
22
+ */
23
+ class PlayList {
24
+ title;
25
+ fileAddress;
26
+ }
27
+ function playListToMediaSource(item) {
28
+ return { src: item.fileAddress, title: item.title };
29
+ }
30
+ /**
31
+ * Content equality for `NgxMediaSource`/playlist arrays, not reference
32
+ * equality. `src` strings compare by value; `Blob`/`File`/`MediaStream`
33
+ * compare by identity (they can't be compared by content cheaply/safely).
34
+ *
35
+ * Every field that can change *downstream behavior* is compared —
36
+ * `artist`/`album`/`poster` feed Media Session metadata, `downloadUrl`/
37
+ * `downloadFileName` change what the download button actually does, and
38
+ * `requestHeaders` changes what bytes a server returns for the same URL.
39
+ * Fields that are purely cosmetic and never drive a decision (there
40
+ * currently are none — if you add one, document why it's excluded here).
41
+ *
42
+ * This exists specifically so a consumer that re-creates an equivalent
43
+ * array/object literal on every change-detection cycle (`[sources]="[{...}]"`,
44
+ * or a `.map()` derived from an otherwise-stable list) doesn't cause the
45
+ * player to treat it as "a new playlist" and reload media on every tick —
46
+ * see `computed(..., { equal: mediaSourcesEqual })` in `NgxMediaControl` and
47
+ * the same guard in `NgxMediaFacade.setPlaylist`.
48
+ */
49
+ function mediaSourcesEqual(a, b) {
50
+ if (a === b)
51
+ return true;
52
+ if (a.length !== b.length)
53
+ return false;
54
+ for (let i = 0; i < a.length; i++) {
55
+ if (!mediaSourceEqual(a[i], b[i]))
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ function mediaSourceEqual(a, b) {
61
+ if (a === b)
62
+ return true;
63
+ return (a.src === b.src && // strings compare by value; Blob/File/MediaStream by identity — both correct via `===`
64
+ a.type === b.type &&
65
+ a.codecs === b.codecs &&
66
+ a.title === b.title &&
67
+ a.artist === b.artist &&
68
+ a.album === b.album &&
69
+ a.poster === b.poster &&
70
+ a.downloadUrl === b.downloadUrl &&
71
+ a.downloadFileName === b.downloadFileName &&
72
+ headersEqual(a.requestHeaders, b.requestHeaders));
73
+ }
74
+ function headersEqual(a, b) {
75
+ if (a === b)
76
+ return true;
77
+ if (!a || !b)
78
+ return false;
79
+ const na = normalizeHeaders(a);
80
+ const nb = normalizeHeaders(b);
81
+ if (na.length !== nb.length)
82
+ return false;
83
+ for (let i = 0; i < na.length; i++) {
84
+ if (na[i][0] !== nb[i][0] || na[i][1] !== nb[i][1])
85
+ return false;
86
+ }
87
+ return true;
88
+ }
89
+ function normalizeHeaders(h) {
90
+ const entries = h instanceof Headers ? Array.from(h.entries()) : Array.isArray(h) ? h : Object.entries(h);
91
+ return entries.map(([k, v]) => [k.toLowerCase(), v]).sort((x, y) => (x[0] < y[0] ? -1 : x[0] > y[0] ? 1 : 0));
92
+ }
93
+ /**
94
+ * Derives a display file name from a source, tolerating everything a real
95
+ * URL can throw at `decodeURIComponent` (malformed `%` sequences, stray
96
+ * `#`/`?`) instead of letting the player crash just because it tried to
97
+ * make a pretty label. Never throws.
98
+ */
99
+ function extractMediaFileName(source) {
100
+ if (source.title)
101
+ return source.title;
102
+ if (typeof source.src !== 'string') {
103
+ if (source.src instanceof File && source.src.name)
104
+ return source.src.name;
105
+ return 'no name';
106
+ }
107
+ try {
108
+ const withoutHash = source.src.split('#')[0];
109
+ const withoutQuery = withoutHash.split('?')[0];
110
+ const lastSegment = withoutQuery.replace(/\\/g, '/').split('/').filter(Boolean).pop();
111
+ if (!lastSegment)
112
+ return 'no name';
113
+ try {
114
+ return decodeURIComponent(lastSegment);
115
+ }
116
+ catch {
117
+ // Malformed percent-encoding (e.g. a lone "%" or "%zz") — fall back to
118
+ // the raw, un-decoded segment rather than throwing.
119
+ return lastSegment;
120
+ }
121
+ }
122
+ catch {
123
+ return 'no name';
124
+ }
125
+ }
126
+
127
+ function initialMediaState() {
128
+ return {
129
+ playback: 'idle',
130
+ currentTime: 0,
131
+ duration: 0,
132
+ buffered: [],
133
+ volume: 1,
134
+ muted: false,
135
+ playbackRate: 1,
136
+ };
137
+ }
138
+ const VALID_TRANSITIONS = {
139
+ idle: new Set(['loading', 'destroyed']),
140
+ loading: new Set(['ready', 'buffering', 'error', 'destroyed']),
141
+ ready: new Set(['playing', 'seeking', 'loading', 'error', 'destroyed']),
142
+ playing: new Set(['paused', 'buffering', 'seeking', 'ended', 'error', 'destroyed']),
143
+ paused: new Set(['playing', 'seeking', 'loading', 'error', 'destroyed']),
144
+ buffering: new Set(['playing', 'paused', 'error', 'destroyed']),
145
+ seeking: new Set(['playing', 'paused', 'error', 'destroyed']),
146
+ ended: new Set(['loading', 'playing', 'destroyed']),
147
+ error: new Set(['loading', 'idle', 'destroyed']),
148
+ destroyed: new Set([]),
149
+ };
150
+ /** Guards against illegal transitions (e.g. anything -> playing after destroyed). */
151
+ function canTransition(from, to) {
152
+ if (from === to)
153
+ return true;
154
+ return VALID_TRANSITIONS[from].has(to);
155
+ }
156
+
157
+ class NgxMediaError extends Error {
158
+ category;
159
+ cause;
160
+ constructor(category, message,
161
+ /** Original DOM/browser error, if any — kept for `console.error`/telemetry, not surfaced to templates by default. */
162
+ cause) {
163
+ super(message);
164
+ this.category = category;
165
+ this.cause = cause;
166
+ this.name = 'NgxMediaError';
167
+ }
168
+ static fromMediaElementError(el) {
169
+ const err = el.error;
170
+ const noSource = el.networkState === HTMLMediaElement.NETWORK_NO_SOURCE;
171
+ if (noSource) {
172
+ return new NgxMediaError('NETWORK', 'Could not load media source.', err);
173
+ }
174
+ switch (err?.code) {
175
+ case MediaError.MEDIA_ERR_ABORTED:
176
+ return new NgxMediaError('ABORTED', 'Media playback was aborted.', err);
177
+ case MediaError.MEDIA_ERR_NETWORK:
178
+ return new NgxMediaError('NETWORK', 'A network error interrupted media loading.', err);
179
+ case MediaError.MEDIA_ERR_DECODE:
180
+ return new NgxMediaError('DECODER', 'The media could not be decoded.', err);
181
+ case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
182
+ return new NgxMediaError('UNSUPPORTED_FORMAT', 'This media format/source is not supported.', err);
183
+ default:
184
+ return new NgxMediaError('UNKNOWN', 'An unknown media error occurred.', err);
185
+ }
186
+ }
187
+ static autoplayBlocked(cause) {
188
+ return new NgxMediaError('AUTOPLAY_BLOCKED', 'Playback was blocked by the browser autoplay policy; a user gesture is required.', cause);
189
+ }
190
+ static unsupported(message) {
191
+ return new NgxMediaError('BROWSER_UNSUPPORTED', message);
192
+ }
193
+ static noDecoder(source) {
194
+ return new NgxMediaError('UNSUPPORTED_FORMAT', `No native support and no registered NGX_MEDIA_DECODER can handle "${source}".`);
195
+ }
196
+ }
197
+
198
+ /** True only in a real browser context — guards every browser API access for SSR. */
199
+ function isBrowser() {
200
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
201
+ }
202
+ function hasMediaSource() {
203
+ return isBrowser() && 'MediaSource' in window;
204
+ }
205
+ function hasAudioContext() {
206
+ return isBrowser() && ('AudioContext' in window || 'webkitAudioContext' in window);
207
+ }
208
+ function getAudioContextCtor() {
209
+ if (!hasAudioContext())
210
+ return undefined;
211
+ return window.AudioContext ?? window.webkitAudioContext;
212
+ }
213
+ function hasMediaSession() {
214
+ return isBrowser() && 'mediaSession' in navigator;
215
+ }
216
+ function hasPictureInPicture() {
217
+ return (isBrowser() &&
218
+ typeof HTMLVideoElement !== 'undefined' &&
219
+ 'requestPictureInPicture' in HTMLVideoElement.prototype &&
220
+ // Safari exposes a document-level flag too; either is sufficient.
221
+ document.pictureInPictureEnabled !== false);
222
+ }
223
+ function hasFullscreen() {
224
+ return isBrowser() && (document.fullscreenEnabled || document.webkitFullscreenEnabled);
225
+ }
226
+ /**
227
+ * Best-effort container/codec detection using `canPlayType`. Never trusts the
228
+ * file extension alone: extension is only a last-resort hint when neither
229
+ * `type` nor `codecs` was supplied.
230
+ */
231
+ function canPlayNatively(el, source) {
232
+ const mime = resolveMimeType(source);
233
+ if (!mime)
234
+ return 'maybe'; // unknown — let the native element try; it will fail fast via the `error` event if wrong.
235
+ return el?.canPlayType(mime);
236
+ }
237
+ function resolveMimeType(source) {
238
+ if (source.type) {
239
+ return source.codecs ? `${source.type}; codecs="${source.codecs}"` : source.type;
240
+ }
241
+ if (typeof source.src === 'string') {
242
+ return extensionToMime(source.src);
243
+ }
244
+ if (source.src instanceof File && source.src.type) {
245
+ return source.src.type;
246
+ }
247
+ if (source.src instanceof Blob && source.src.type) {
248
+ return source.src.type;
249
+ }
250
+ return undefined;
251
+ }
252
+ const EXT_MIME_MAP = {
253
+ mp3: 'audio/mpeg',
254
+ aac: 'audio/aac',
255
+ wav: 'audio/wav',
256
+ ogg: 'audio/ogg',
257
+ oga: 'audio/ogg',
258
+ opus: 'audio/ogg; codecs=opus',
259
+ flac: 'audio/flac',
260
+ m4a: 'audio/mp4',
261
+ weba: 'audio/webm',
262
+ mp4: 'video/mp4',
263
+ m4v: 'video/mp4',
264
+ webm: 'video/webm',
265
+ mov: 'video/quicktime',
266
+ ogv: 'video/ogg',
267
+ };
268
+ /** Extension is only ever a weak fallback hint, never treated as ground truth. */
269
+ function extensionToMime(src) {
270
+ const clean = src.split(/[?#]/)[0];
271
+ const ext = clean.split('.').pop()?.toLowerCase();
272
+ return ext ? EXT_MIME_MAP[ext] : undefined;
273
+ }
274
+
275
+ function headersKey(headers) {
276
+ if (!headers)
277
+ return '';
278
+ const entries = headers instanceof Headers
279
+ ? Array.from(headers.entries())
280
+ : Array.isArray(headers)
281
+ ? headers
282
+ : Object.entries(headers);
283
+ return entries
284
+ .map(([k, v]) => `${k.toLowerCase()}=${v}`)
285
+ .sort()
286
+ .join('&');
287
+ }
288
+ function computeIdentity(source) {
289
+ if (typeof source.src === 'string') {
290
+ return { kind: 'url', key: `${source.src}::${headersKey(source.requestHeaders)}` };
291
+ }
292
+ return { kind: 'ref', ref: source.src };
293
+ }
294
+ function sameIdentity(a, b) {
295
+ if (!a || !b || a.kind !== b.kind)
296
+ return false;
297
+ return a.kind === 'url'
298
+ ? a.key === b.key
299
+ : a.ref === b.ref;
300
+ }
301
+ function rangesEqual(a, b) {
302
+ if (a.length !== b.length)
303
+ return false;
304
+ for (let i = 0; i < a.length; i++) {
305
+ if (a[i].start !== b[i].start || a[i].end !== b[i].end)
306
+ return false;
307
+ }
308
+ return true;
309
+ }
310
+ /**
311
+ * Default fast-path engine: plays through a real `<audio>`/`<video>` element.
312
+ * Zero WASM, zero MediaSource — the browser's own decode pipeline does the work.
313
+ *
314
+ * Load semantics (the part that matters most for network behavior):
315
+ * - `load()` is a real 3-state machine (idle/loading/ready) keyed on
316
+ * {@link SourceIdentity}, not just a `readyState` snapshot:
317
+ * same source + currently loading -> returns the SAME in-flight promise
318
+ * same source + already loaded -> resolves immediately, no `el.load()` call
319
+ * different source -> proceeds; the browser's own load
320
+ * algorithm aborts whatever the
321
+ * element was doing before
322
+ * - Setting `el.src` already queues the browser's media load algorithm;
323
+ * the immediate `el.load()` call right after does not cause a second
324
+ * network request — its purpose is to make the abort-then-restart
325
+ * synchronous (needed for older WebKit), not to "load twice". The state
326
+ * machine above is what actually prevents redundant fetches across
327
+ * repeated `load()` calls for the same source.
328
+ *
329
+ * Performance notes:
330
+ * - `timeupdate`/`progress`/etc. fire far more often than the UI needs to
331
+ * re-render; listeners run via `runOutsideAngular`, and `patch()` skips
332
+ * the signal write entirely when nothing actually changed (including a
333
+ * content-equal `buffered` array, which would otherwise allocate and
334
+ * write on every `progress` tick even when the ranges didn't move).
335
+ */
336
+ class NativeMediaEngine {
337
+ runOutsideAngular;
338
+ id = 'native';
339
+ kind;
340
+ _state = signal(initialMediaState(), /* @ts-ignore */
341
+ ...(ngDevMode ? [{ debugName: "_state" }] : /* istanbul ignore next */ []));
342
+ state = this._state.asReadonly();
343
+ el;
344
+ objectUrl = null;
345
+ destroyed = false;
346
+ cleanupFns = [];
347
+ loadState = 'idle';
348
+ loadedIdentity = null;
349
+ pendingLoad = null;
350
+ activeLoadController = null;
351
+ constructor(kind,
352
+ /** Provided by the facade so listener callbacks never re-enter Angular's zone. */
353
+ runOutsideAngular = (fn) => fn(), existingElement, preload = 'metadata') {
354
+ this.runOutsideAngular = runOutsideAngular;
355
+ this.kind = kind;
356
+ this.el = existingElement ?? document.createElement(kind);
357
+ this.el.preload = preload;
358
+ this.el.setAttribute('playsinline', 'true');
359
+ this.attachListeners();
360
+ }
361
+ get mediaElement() {
362
+ return this.el;
363
+ }
364
+ async load(source, abort) {
365
+ this.assertNotDestroyed();
366
+ const identity = computeIdentity(source);
367
+ if (sameIdentity(identity, this.loadedIdentity)) {
368
+ if (this.loadState === 'loading' && this.pendingLoad) {
369
+ // Same source, still in flight — hand back the SAME promise instead
370
+ // of starting a second (redundant) load for something already underway.
371
+ return this.pendingLoad;
372
+ }
373
+ if (this.loadState === 'ready') {
374
+ // Same source, already fully loaded — nothing to do, no `el.load()` call.
375
+ return Promise.resolve();
376
+ }
377
+ }
378
+ // A different source supersedes the previous load even when the caller
379
+ // didn't provide/abort its own signal. This prevents stale
380
+ // loadedmetadata/error events from resolving the new request.
381
+ this.activeLoadController?.abort();
382
+ const controller = new AbortController();
383
+ const onExternalAbort = () => controller.abort();
384
+ if (abort?.aborted)
385
+ controller.abort();
386
+ else
387
+ abort?.addEventListener('abort', onExternalAbort, { once: true });
388
+ const promise = this.performLoad(source, identity, controller.signal).finally(() => {
389
+ abort?.removeEventListener('abort', onExternalAbort);
390
+ if (this.pendingLoad === promise)
391
+ this.pendingLoad = null;
392
+ if (this.activeLoadController === controller)
393
+ this.activeLoadController = null;
394
+ });
395
+ this.activeLoadController = controller;
396
+ this.pendingLoad = promise;
397
+ return promise;
398
+ }
399
+ async performLoad(source, identity, abort) {
400
+ this.loadedIdentity = identity;
401
+ this.loadState = 'loading';
402
+ this.releaseObjectUrl();
403
+ this.patch({ playback: 'loading', currentTime: 0, duration: 0, buffered: [] });
404
+ try {
405
+ if (abort?.aborted)
406
+ throw new NgxMediaError('ABORTED', 'Load aborted.');
407
+ const capability = canPlayNatively(this.el, source);
408
+ if (capability === '') {
409
+ throw NgxMediaError.noDecoder(typeof source.src === 'string' ? source.src : '[blob]');
410
+ }
411
+ const resolvedSrc = this.resolveSrc(source);
412
+ await new Promise((resolve, reject) => {
413
+ const onReady = () => {
414
+ cleanup();
415
+ resolve();
416
+ };
417
+ const onError = () => {
418
+ cleanup();
419
+ reject(NgxMediaError.fromMediaElementError(this.el));
420
+ };
421
+ const onAbort = () => {
422
+ cleanup();
423
+ reject(new NgxMediaError('ABORTED', 'Load aborted.'));
424
+ };
425
+ const cleanup = () => {
426
+ this.el.removeEventListener('loadedmetadata', onReady);
427
+ this.el.removeEventListener('error', onError);
428
+ abort?.removeEventListener('abort', onAbort);
429
+ };
430
+ this.el.addEventListener('loadedmetadata', onReady, { once: true });
431
+ this.el.addEventListener('error', onError, { once: true });
432
+ abort?.addEventListener('abort', onAbort, { once: true });
433
+ if (abort?.aborted) {
434
+ onAbort();
435
+ return;
436
+ }
437
+ // Setting src/srcObject is enough to select the resource and avoids
438
+ // immediately restarting the same selection algorithm with an extra
439
+ // `load()` call. This is important for authenticated/redirected
440
+ // resources where a restart can result in another network request.
441
+ if (typeof MediaStream !== 'undefined' && source.src instanceof MediaStream) {
442
+ this.el.srcObject = source.src;
443
+ }
444
+ else {
445
+ this.el.src = resolvedSrc;
446
+ }
447
+ });
448
+ this.loadState = 'ready';
449
+ }
450
+ catch (err) {
451
+ this.loadState = 'idle';
452
+ this.loadedIdentity = null;
453
+ throw err;
454
+ }
455
+ }
456
+ async play() {
457
+ this.assertNotDestroyed();
458
+ try {
459
+ await this.el.play();
460
+ }
461
+ catch (err) {
462
+ // NotAllowedError is the browser's autoplay-policy rejection.
463
+ if (err instanceof DOMException && err.name === 'NotAllowedError') {
464
+ this.patch({ playback: 'paused' });
465
+ throw NgxMediaError.autoplayBlocked(err);
466
+ }
467
+ throw err;
468
+ }
469
+ }
470
+ pause() {
471
+ if (this.destroyed)
472
+ return;
473
+ this.el.pause();
474
+ }
475
+ seek(time) {
476
+ this.assertNotDestroyed();
477
+ const clamped = Math.min(Math.max(time, 0), this.el.duration || time);
478
+ this.patch({ playback: 'seeking' });
479
+ this.el.currentTime = clamped;
480
+ }
481
+ setVolume(volume) {
482
+ this.el.volume = Math.min(Math.max(volume, 0), 1);
483
+ }
484
+ setMuted(muted) {
485
+ this.el.muted = muted;
486
+ }
487
+ setPlaybackRate(rate) {
488
+ this.el.playbackRate = Math.max(0.0625, rate);
489
+ }
490
+ destroy() {
491
+ if (this.destroyed)
492
+ return;
493
+ this.destroyed = true;
494
+ this.cleanupFns.forEach((fn) => fn());
495
+ this.cleanupFns.length = 0;
496
+ this.releaseObjectUrl();
497
+ this.loadState = 'idle';
498
+ this.loadedIdentity = null;
499
+ this.pendingLoad = null;
500
+ this.activeLoadController?.abort();
501
+ this.activeLoadController = null;
502
+ this.el.pause();
503
+ this.el.removeAttribute('src');
504
+ this.el.srcObject = null;
505
+ this.el.load();
506
+ this.patch({ playback: 'destroyed' });
507
+ }
508
+ // --- internals -----------------------------------------------------------
509
+ resolveSrc(source) {
510
+ if (typeof source.src === 'string')
511
+ return source.src;
512
+ if (source.src instanceof Blob) {
513
+ this.objectUrl = URL.createObjectURL(source.src);
514
+ return this.objectUrl;
515
+ }
516
+ return '';
517
+ }
518
+ releaseObjectUrl() {
519
+ if (this.objectUrl) {
520
+ URL.revokeObjectURL(this.objectUrl);
521
+ this.objectUrl = null;
522
+ }
523
+ }
524
+ /** Skips the signal write entirely when every field in `partial` is already equal to current state. */
525
+ patch(partial) {
526
+ this._state.update((s) => {
527
+ let changed = false;
528
+ for (const k in partial) {
529
+ if (partial[k] !== s[k]) {
530
+ changed = true;
531
+ break;
532
+ }
533
+ }
534
+ return changed ? { ...s, ...partial } : s;
535
+ });
536
+ }
537
+ bufferedRanges() {
538
+ const ranges = [];
539
+ const b = this.el.buffered;
540
+ for (let i = 0; i < b.length; i++) {
541
+ ranges.push({ start: b.start(i), end: b.end(i) });
542
+ }
543
+ return ranges;
544
+ }
545
+ attachListeners() {
546
+ this.runOutsideAngular(() => {
547
+ const on = (type, handler) => {
548
+ this.el.addEventListener(type, handler);
549
+ this.cleanupFns.push(() => this.el.removeEventListener(type, handler));
550
+ };
551
+ on('loadedmetadata', () => {
552
+ this.patch({
553
+ duration: Number.isFinite(this.el.duration) ? this.el.duration : 0,
554
+ videoWidth: this.el.videoWidth || undefined,
555
+ videoHeight: this.el.videoHeight || undefined,
556
+ playback: 'ready',
557
+ });
558
+ // Deliberately NOT auto-probing an unknown duration here — see
559
+ // `probeUnseekableDuration()` below for why that cost extra network
560
+ // requests on every load. `durationchange` still updates the UI the
561
+ // moment the browser resolves it on its own, at zero extra cost.
562
+ });
563
+ on('waiting', () => this.patch({ playback: 'buffering' }));
564
+ on('playing', () => this.patch({ playback: 'playing' }));
565
+ on('pause', () => {
566
+ if (this._state().playback !== 'ended')
567
+ this.patch({ playback: 'paused' });
568
+ });
569
+ on('ended', () => this.patch({ playback: 'ended' }));
570
+ on('seeked', () => this.patch({
571
+ playback: this.el.paused ? 'paused' : 'playing',
572
+ currentTime: this.el.currentTime,
573
+ }));
574
+ on('timeupdate', () => this.patch({ currentTime: this.el.currentTime }));
575
+ on('durationchange', () => {
576
+ if (Number.isFinite(this.el.duration))
577
+ this.patch({ duration: this.el.duration });
578
+ });
579
+ on('progress', () => {
580
+ const ranges = this.bufferedRanges();
581
+ // `bufferedRanges()` always allocates a new array; only write the
582
+ // signal (and thus schedule a render) if the ranges actually moved.
583
+ if (!rangesEqual(ranges, this._state().buffered)) {
584
+ this.patch({ buffered: ranges });
585
+ }
586
+ });
587
+ on('volumechange', () => this.patch({ volume: this.el.volume, muted: this.el.muted }));
588
+ on('ratechange', () => this.patch({ playbackRate: this.el.playbackRate }));
589
+ on('error', () => this.patch({ playback: 'error' }));
590
+ });
591
+ }
592
+ /**
593
+ * Some servers/streams report `duration === Infinity` until more data
594
+ * arrives. This forces the browser to resolve it immediately by seeking
595
+ * near the end and back — which works, but each call costs 1-2 extra
596
+ * HTTP range requests. No longer called automatically; opt-in for the
597
+ * rare case where you genuinely need duration *immediately* rather than
598
+ * letting `durationchange` update it naturally during normal playback.
599
+ */
600
+ async probeUnseekableDuration() {
601
+ if (this.destroyed)
602
+ return;
603
+ const original = this.el.currentTime;
604
+ try {
605
+ this.el.currentTime = 1e10;
606
+ await new Promise((resolve) => {
607
+ const onChange = () => {
608
+ this.el.removeEventListener('durationchange', onChange);
609
+ resolve();
610
+ };
611
+ this.el.addEventListener('durationchange', onChange, { once: true });
612
+ setTimeout(resolve, 1500); // don't hang forever on streams that never resolve it
613
+ });
614
+ }
615
+ finally {
616
+ if (!this.destroyed)
617
+ this.el.currentTime = original;
618
+ }
619
+ }
620
+ assertNotDestroyed() {
621
+ if (this.destroyed) {
622
+ throw new NgxMediaError('ABORTED', `${this.id} engine method called after destroy().`);
623
+ }
624
+ }
625
+ }
626
+ /** True if an `AudioContext`-based duration probe is even worth attempting (SSR/very old browser guard). */
627
+ function canProbeDuration() {
628
+ return !!getAudioContextCtor();
629
+ }
630
+
631
+ /**
632
+ * Multi-provider token: every decoder a consumer registers via
633
+ * `provideNgxMediaDecoder()` is picked up automatically by the registry.
634
+ *
635
+ * IMPORTANT: this token intentionally has NO default factory. Angular does
636
+ * not allow mixing a token's own default provider with externally
637
+ * registered `multi: true` providers for the SAME token — doing so throws
638
+ * at DI-construction time the moment a consumer actually registers a
639
+ * decoder (a real, if latent, bug this fixes). The correct pattern for an
640
+ * "optionally empty" multi token is `@Optional()` injection at the
641
+ * consumption site (see `NgxMediaDecoderRegistry`), which yields `null`
642
+ * when nothing has registered and an array when one or more have.
643
+ */
644
+ const NGX_MEDIA_DECODER = new InjectionToken('NGX_MEDIA_DECODER');
645
+ const NGX_MEDIA_REQUEST_HANDLER = new InjectionToken('NGX_MEDIA_REQUEST_HANDLER', {
646
+ providedIn: 'root',
647
+ factory: () => ({
648
+ request: (source, signal) => fetch(typeof source.src === 'string' ? source.src : '', {
649
+ headers: source.requestHeaders,
650
+ signal,
651
+ }),
652
+ }),
653
+ });
654
+ const NGX_MEDIA_CONFIG_DEFAULTS = {
655
+ preload: 'metadata',
656
+ maxConcurrentDecoderProbes: 3,
657
+ };
658
+ const NGX_MEDIA_CONFIG = new InjectionToken('NGX_MEDIA_CONFIG', {
659
+ providedIn: 'root',
660
+ factory: () => ({ ...NGX_MEDIA_CONFIG_DEFAULTS }),
661
+ });
662
+
663
+ /**
664
+ * Only ever returns a native engine (not yet loaded). `canPlayType()`/
665
+ * extension/mime are just a hint and can lie (fake extension, a container
666
+ * the browser opens but can't actually decode) — the only reliable signal
667
+ * is a real `load()` attempt actually failing. So the native-vs-decoder
668
+ * decision is not made here; `NgxMediaFacade.resolveEngine()` tries native
669
+ * first and falls back to the decoder registry on a genuine (format-level)
670
+ * failure. This class stays responsible only for constructing engines.
671
+ *
672
+ * Synchronous on purpose: constructing a `NativeMediaEngine` never awaits
673
+ * anything, so wrapping it in a `Promise` bought nothing but an extra
674
+ * microtask on every load. Callers that still `await` this are unaffected.
675
+ */
676
+ class NgxMediaEngineFactory {
677
+ requestHandler = inject(NGX_MEDIA_REQUEST_HANDLER);
678
+ create(kind, runOutsideAngular, preload = 'metadata') {
679
+ if (!isBrowser()) {
680
+ throw NgxMediaError.unsupported('Media engines can only be created in a browser context (SSR guard).');
681
+ }
682
+ return new NativeMediaEngine(kind, runOutsideAngular, undefined, preload);
683
+ }
684
+ /** Explicit opt-in path for callers that specifically want MSE (e.g. a decoder building on it). */
685
+ async createMse(kind) {
686
+ const { MseMediaEngine } = await import('./ngx-kit-media-control-mse-media-engine-CtP97cb9.mjs');
687
+ return new MseMediaEngine(kind, this.requestHandler);
688
+ }
689
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaEngineFactory, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
690
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaEngineFactory, providedIn: 'root' });
691
+ }
692
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaEngineFactory, decorators: [{
693
+ type: Injectable,
694
+ args: [{ providedIn: 'root' }]
695
+ }] });
696
+
697
+ /**
698
+ * `navigator.mediaSession` is a single global the whole page shares. With
699
+ * more than one `NgxMediaControl` on a page, whichever bound LAST naturally
700
+ * owns it (a reasonable policy — that matches which one the OS media
701
+ * controls should act on). The bug this fixes: without tracking who the
702
+ * current owner actually is, destroying an OLDER player unconditionally
703
+ * cleared every action handler, silently breaking the media session for
704
+ * whichever player is currently active. `currentOwner` makes `unbind()` a
705
+ * no-op unless the instance calling it is still the one that owns it.
706
+ */
707
+ let currentOwner = null;
708
+ /** No-ops everywhere the API isn't supported; never throws. */
709
+ class NgxMediaSessionBridge {
710
+ bound = false;
711
+ bind(actions) {
712
+ if (!hasMediaSession())
713
+ return;
714
+ currentOwner = this;
715
+ this.bound = true;
716
+ const ms = navigator.mediaSession;
717
+ const safe = (fn) => () => {
718
+ try {
719
+ fn();
720
+ }
721
+ catch {
722
+ /* a handler throwing must never break media session integration */
723
+ }
724
+ };
725
+ ms.setActionHandler('play', safe(actions.play));
726
+ ms.setActionHandler('pause', safe(actions.pause));
727
+ ms.setActionHandler('previoustrack', safe(actions.previous));
728
+ ms.setActionHandler('nexttrack', safe(actions.next));
729
+ ms.setActionHandler('seekbackward', (details) => actions.seekBackward(details.seekOffset ?? 10));
730
+ ms.setActionHandler('seekforward', (details) => actions.seekForward(details.seekOffset ?? 10));
731
+ }
732
+ /** No-ops if a different (typically newer) instance currently owns the session — a stale player must never overwrite the active one's metadata. */
733
+ setMetadata(source) {
734
+ if (!hasMediaSession() || typeof MediaMetadata === 'undefined' || currentOwner !== this)
735
+ return;
736
+ navigator.mediaSession.metadata = new MediaMetadata({
737
+ title: source.title ?? '',
738
+ artist: source.artist ?? '',
739
+ album: source.album ?? '',
740
+ artwork: source.poster ? [{ src: source.poster }] : [],
741
+ });
742
+ }
743
+ setPlaybackState(state) {
744
+ if (!hasMediaSession() || currentOwner !== this)
745
+ return;
746
+ navigator.mediaSession.playbackState = state;
747
+ }
748
+ unbind() {
749
+ if (!hasMediaSession() || !this.bound)
750
+ return;
751
+ this.bound = false;
752
+ if (currentOwner !== this) {
753
+ // A newer instance already took over ownership — clearing handlers
754
+ // now would wipe out THEIR session, not ours. Nothing to do.
755
+ return;
756
+ }
757
+ currentOwner = null;
758
+ const ms = navigator.mediaSession;
759
+ ['play', 'pause', 'previoustrack', 'nexttrack', 'seekbackward', 'seekforward'].forEach((action) => {
760
+ try {
761
+ ms.setActionHandler(action, null);
762
+ }
763
+ catch {
764
+ /* older browsers may not support every action name */
765
+ }
766
+ });
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Optional processing graph: `<audio>/<video>` → MediaElementAudioSourceNode
772
+ * → gain → (optional analyser) → destination.
773
+ *
774
+ * Never constructed unless a consumer asks for gain/analyser/effects — plain
775
+ * playback never creates an `AudioContext`, respecting autoplay policy and
776
+ * avoiding the "one AudioContext per instance whether needed or not" issue
777
+ * in the original component.
778
+ *
779
+ * Autoplay note: this class never calls `resume()` on its own outside a
780
+ * user-triggered `play()`/`connect()` call — bypassing the suspended-state
781
+ * policy is explicitly out of scope.
782
+ */
783
+ class NgxWebAudioGraph {
784
+ element;
785
+ ctx = null;
786
+ sourceNode = null;
787
+ gainNode = null;
788
+ analyserNode = null;
789
+ constructor(element) {
790
+ this.element = element;
791
+ }
792
+ /** Idempotent — safe to call multiple times; only the first call does any work. */
793
+ ensureConnected() {
794
+ if (this.ctx && this.gainNode)
795
+ return { context: this.ctx, gain: this.gainNode };
796
+ const Ctor = getAudioContextCtor();
797
+ if (!Ctor)
798
+ throw NgxMediaError.unsupported('Web Audio API is not available in this browser.');
799
+ this.ctx = new Ctor();
800
+ this.sourceNode = this.ctx.createMediaElementSource(this.element);
801
+ this.gainNode = this.ctx.createGain();
802
+ this.sourceNode.connect(this.gainNode);
803
+ this.gainNode.connect(this.ctx.destination);
804
+ return { context: this.ctx, gain: this.gainNode };
805
+ }
806
+ setGain(value) {
807
+ if (this.gainNode)
808
+ this.gainNode.gain.value = value;
809
+ }
810
+ getAnalyser(fftSize = 2048) {
811
+ if (!this.ctx || !this.gainNode)
812
+ this.ensureConnected();
813
+ if (!this.analyserNode) {
814
+ this.analyserNode = this.ctx.createAnalyser();
815
+ this.analyserNode.fftSize = fftSize;
816
+ this.gainNode.connect(this.analyserNode);
817
+ }
818
+ return this.analyserNode;
819
+ }
820
+ async resumeOnUserGesture() {
821
+ if (this.ctx?.state === 'suspended')
822
+ await this.ctx.resume();
823
+ }
824
+ destroy() {
825
+ this.sourceNode?.disconnect();
826
+ this.gainNode?.disconnect();
827
+ this.analyserNode?.disconnect();
828
+ // Do not call ctx.close() synchronously in destroy paths that might race a
829
+ // pending resume(); schedule it and swallow errors from an already-closed context.
830
+ this.ctx?.close().catch(() => { });
831
+ this.ctx = null;
832
+ this.sourceNode = null;
833
+ this.gainNode = null;
834
+ this.analyserNode = null;
835
+ }
836
+ }
837
+
838
+ function extensionOf(source) {
839
+ if (typeof source.src !== 'string')
840
+ return undefined;
841
+ return source.src.split(/[?#]/)[0].split('.').pop()?.toLowerCase();
842
+ }
843
+ /**
844
+ * Resolves a non-natively-playable source to a registered decoder, highest
845
+ * `priority` first. Nothing here imports MSE/WASM code directly — decoders
846
+ * bring their own (lazily, inside `createEngine`), so a consumer who never
847
+ * registers a decoder never pays for one in their bundle.
848
+ *
849
+ * Probe strategy:
850
+ * 1. Cheap synchronous pre-filter by `formats` (extension match) first —
851
+ * a decoder that lists `formats: ['3gp']` is never even asked
852
+ * `canDecode()` for an `.mp3` source.
853
+ * 2. Remaining candidates are probed with bounded concurrency
854
+ * (`maxConcurrentDecoderProbes`) rather than one at a time, so N
855
+ * network/CPU-bound `canDecode()` checks don't serialize their latency.
856
+ * 3. The whole probe is cancellable via `signal` (passed down from the
857
+ * facade's per-load `AbortController`), so switching tracks quickly
858
+ * doesn't leave abandoned probes burning CPU/network.
859
+ */
860
+ class NgxMediaDecoderRegistry {
861
+ decoders;
862
+ config = inject(NGX_MEDIA_CONFIG);
863
+ /** Set by a consumer that wants visibility into swallowed decoder errors (logging/telemetry), instead of them being silently invisible. */
864
+ onDecoderFailure = null;
865
+ constructor(decoders) {
866
+ this.decoders = [...(decoders ?? [])].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
867
+ }
868
+ async findDecoder(source, signal) {
869
+ if (this.decoders.length === 0)
870
+ return undefined;
871
+ // Step 1: cheap, synchronous pre-filter. A decoder with a non-empty
872
+ // `formats` list is only a candidate if the source's extension matches
873
+ // (or the source has no discoverable extension at all, in which case we
874
+ // can't rule it out cheaply and let it through to the real probe).
875
+ const ext = extensionOf(source);
876
+ const candidates = this.decoders.filter((d) => d.formats.length === 0 || !ext || d.formats.includes(ext));
877
+ if (candidates.length === 0)
878
+ return undefined;
879
+ // Step 2 + 3: bounded-concurrency, cancellable probing, highest
880
+ // priority first among each concurrent batch (candidates is already
881
+ // priority-sorted, so batching preserves that order for the final pick).
882
+ const limit = Math.max(1, this.config.maxConcurrentDecoderProbes);
883
+ for (let i = 0; i < candidates.length; i += limit) {
884
+ if (signal?.aborted)
885
+ return undefined;
886
+ const batch = candidates.slice(i, i + limit);
887
+ const results = await Promise.all(batch.map(async (decoder) => {
888
+ try {
889
+ return (await decoder.canDecode(source, signal)) ? decoder : null;
890
+ }
891
+ catch (error) {
892
+ if (signal?.aborted)
893
+ return null;
894
+ // A misbehaving decoder shouldn't block evaluating the rest,
895
+ // but the failure is no longer silently swallowed.
896
+ this.onDecoderFailure?.({ decoderId: decoder.id, error });
897
+ return null;
898
+ }
899
+ }));
900
+ if (signal?.aborted)
901
+ return undefined;
902
+ const found = results.find((d) => d !== null);
903
+ if (found)
904
+ return found;
905
+ }
906
+ return undefined;
907
+ }
908
+ async createEngine(source, signal) {
909
+ const decoder = await this.findDecoder(source, signal);
910
+ if (!decoder) {
911
+ throw NgxMediaError.noDecoder(typeof source.src === 'string' ? source.src : '[blob]');
912
+ }
913
+ return decoder.createEngine(source, signal);
914
+ }
915
+ destroyAll() {
916
+ this.decoders.forEach((d) => d.destroy());
917
+ }
918
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaDecoderRegistry, deps: [{ token: NGX_MEDIA_DECODER, optional: true }], target: i0.ɵɵFactoryTarget.Injectable });
919
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaDecoderRegistry, providedIn: 'root' });
920
+ }
921
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaDecoderRegistry, decorators: [{
922
+ type: Injectable,
923
+ args: [{ providedIn: 'root' }]
924
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
925
+ type: Optional
926
+ }, {
927
+ type: Inject,
928
+ args: [NGX_MEDIA_DECODER]
929
+ }] }] });
930
+ /** Injects the registry outside DI (e.g. from a factory function). */
931
+ function injectDecoderRegistry() {
932
+ return inject(NgxMediaDecoderRegistry);
933
+ }
934
+
935
+ /** Categories where falling back to a decoder is actually the right call — i.e. the format/codec itself is the problem, not the network or a deliberate cancellation. */
936
+ const DECODER_FALLBACK_CATEGORIES = new Set(['UNSUPPORTED_FORMAT', 'DECODER']);
937
+ /**
938
+ * Everything the UI component needs, expressed as signals it can bind to
939
+ * directly, plus imperative commands. This is the only class
940
+ * `NgxMediaControl` talks to — it never touches an engine, a decoder, or the
941
+ * DOM media element itself. Swapping native/MSE/WASM playback never touches
942
+ * the component.
943
+ */
944
+ class NgxMediaFacade {
945
+ zone = inject(NgZone);
946
+ engineFactory = inject(NgxMediaEngineFactory);
947
+ decoderRegistry = inject(NgxMediaDecoderRegistry);
948
+ destroyRef = inject(DestroyRef);
949
+ mediaSession = new NgxMediaSessionBridge();
950
+ webAudio = null;
951
+ loadAbort = null;
952
+ kind = 'audio';
953
+ preload = 'metadata';
954
+ /**
955
+ * Belt-and-suspenders alongside `AbortController`: not every operation a
956
+ * superseded load might be mid-way through is actually cancellable (a
957
+ * decoder's own internal work, a promise someone else is holding a
958
+ * reference to, ...). Every `loadCurrent()` call captures the generation
959
+ * counter at its start and checks it again before committing any result —
960
+ * a stale generation means "a newer load has already started", full stop,
961
+ * regardless of whether the stale one's `AbortSignal` was actually honored
962
+ * by everything downstream.
963
+ */
964
+ loadGeneration = 0;
965
+ _playlist = signal([], /* @ts-ignore */
966
+ ...(ngDevMode ? [{ debugName: "_playlist" }] : /* istanbul ignore next */ []));
967
+ _currentIndex = signal(0, /* @ts-ignore */
968
+ ...(ngDevMode ? [{ debugName: "_currentIndex" }] : /* istanbul ignore next */ []));
969
+ /** Holds the live engine instance; swapped whenever a new source loads. */
970
+ _engine = signal(null, /* @ts-ignore */
971
+ ...(ngDevMode ? [{ debugName: "_engine" }] : /* istanbul ignore next */ []));
972
+ get engine() {
973
+ return this._engine();
974
+ }
975
+ _error = signal(null, /* @ts-ignore */
976
+ ...(ngDevMode ? [{ debugName: "_error" }] : /* istanbul ignore next */ []));
977
+ playlist = this._playlist.asReadonly();
978
+ currentIndex = this._currentIndex.asReadonly();
979
+ /**
980
+ * Reads through to the current engine's own state signal, so every native
981
+ * media event (timeupdate, progress, ...) that the engine writes flows
982
+ * straight through here with no polling, no manual refresh, and no risk of
983
+ * going stale across an engine swap (native -> WASM fallback, etc.).
984
+ *
985
+ * When there is NO engine (playlist empty, or the engine was torn down
986
+ * after a load failure) but `error()` is set, this reports `playback:
987
+ * 'error'` rather than falling through to `idle` — otherwise `state()` and
988
+ * `error()` would be two sources of truth that can disagree, and anything
989
+ * built on `state().playback === 'error'` would silently miss real errors.
990
+ */
991
+ state = computed(() => {
992
+ const engineState = this._engine()?.state();
993
+ if (engineState)
994
+ return engineState;
995
+ if (this._error())
996
+ return { ...initialMediaState(), playback: 'error' };
997
+ return initialMediaState();
998
+ }, /* @ts-ignore */
999
+ ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
1000
+ error = this._error.asReadonly();
1001
+ currentSource = computed(() => this._playlist()[this._currentIndex()], /* @ts-ignore */
1002
+ ...(ngDevMode ? [{ debugName: "currentSource" }] : /* istanbul ignore next */ []));
1003
+ mediaElement = computed(() => this._engine()?.mediaElement ?? null, /* @ts-ignore */
1004
+ ...(ngDevMode ? [{ debugName: "mediaElement" }] : /* istanbul ignore next */ []));
1005
+ constructor() {
1006
+ this.destroyRef.onDestroy(() => this.destroy());
1007
+ }
1008
+ /** Deterministic on a kind change: if a source is already active, immediately rebuilds the engine as the new kind (audio <-> video element) instead of leaving a stale-kind engine behind a UI that now expects the other one. */
1009
+ configure(kind) {
1010
+ if (this.kind === kind)
1011
+ return;
1012
+ this.kind = kind;
1013
+ if (this.currentSource()) {
1014
+ const wasPlaying = this.state().playback === 'playing';
1015
+ void this.loadCurrent(wasPlaying);
1016
+ }
1017
+ }
1018
+ /** Applies to loads started after this call — not retroactive to an element already created. */
1019
+ setPreload(preload) {
1020
+ this.preload = preload;
1021
+ }
1022
+ setPlaylist(sources, startIndex = 0, autoLoad = true) {
1023
+ // If the content genuinely hasn't changed, do nothing — not even
1024
+ // reset `currentIndex` to `startIndex`. A component effect that always
1025
+ // passes `startIndex=0` must not be able to yank a user who has
1026
+ // manually navigated (next/previous) back to track 0 just because it
1027
+ // happened to re-run with an equivalent (but reference-different) array.
1028
+ if (mediaSourcesEqual(this._playlist(), sources)) {
1029
+ return;
1030
+ }
1031
+ const clampedIndex = Math.min(Math.max(startIndex, 0), Math.max(sources.length - 1, 0));
1032
+ this._playlist.set(sources);
1033
+ this._currentIndex.set(clampedIndex);
1034
+ if (sources.length === 0) {
1035
+ // Nothing left to play — tear down whatever was active instead of
1036
+ // leaving stale audio/video playing with no playlist entry backing it.
1037
+ this.loadAbort?.abort();
1038
+ this.loadGeneration++;
1039
+ this.webAudio?.destroy();
1040
+ this.webAudio = null;
1041
+ this.engine?.destroy();
1042
+ this._engine.set(null);
1043
+ this._error.set(null);
1044
+ return;
1045
+ }
1046
+ if (autoLoad)
1047
+ void this.loadCurrent();
1048
+ }
1049
+ async loadCurrent(playAfter = false) {
1050
+ const source = this.currentSource();
1051
+ if (!source || !isBrowser())
1052
+ return;
1053
+ this.loadAbort?.abort();
1054
+ const controller = new AbortController();
1055
+ this.loadAbort = controller;
1056
+ const generation = ++this.loadGeneration;
1057
+ this._error.set(null);
1058
+ let newEngine = null;
1059
+ try {
1060
+ newEngine = await this.resolveEngine(source, controller.signal);
1061
+ if (generation !== this.loadGeneration) {
1062
+ // Superseded while the new engine was being built (next/previous/
1063
+ // another loadCurrent started meanwhile). Never commit it, and
1064
+ // never touch the engine that IS current now — that belongs to the
1065
+ // newer, still-in-flight or already-committed load.
1066
+ newEngine.destroy();
1067
+ return;
1068
+ }
1069
+ // Transactional swap: the OLD engine is only destroyed once the NEW
1070
+ // one is confirmed ready. This means a failed next()/previous() never
1071
+ // interrupts what was already playing, and the player never sits in a
1072
+ // null/blank state just because the next track failed to load.
1073
+ const old = this.engine;
1074
+ this.webAudio?.destroy();
1075
+ this.webAudio = null; // a Web Audio graph attached to the OLD element must never be reused for the new one
1076
+ this._engine.set(newEngine);
1077
+ old?.destroy();
1078
+ this.mediaSession.setMetadata(source);
1079
+ this.bindMediaSession();
1080
+ if (playAfter)
1081
+ await this.play();
1082
+ }
1083
+ catch (err) {
1084
+ newEngine?.destroy();
1085
+ if (generation !== this.loadGeneration)
1086
+ return; // a newer load owns error/state now; this failure is stale
1087
+ const mediaError = err instanceof NgxMediaError ? err : new NgxMediaError('UNKNOWN', 'Failed to load media.', err);
1088
+ this._error.set(mediaError);
1089
+ }
1090
+ }
1091
+ /** Forces the current track to actually reload from scratch — e.g. a "retry after error" action — bypassing the engine's own idempotent-load skip (which is otherwise correct: selecting the same, already-loaded track should NOT re-fetch it). */
1092
+ async reloadCurrent() {
1093
+ this.engine?.destroy();
1094
+ this._engine.set(null);
1095
+ await this.loadCurrent(true);
1096
+ }
1097
+ /**
1098
+ * Tries the native engine first (the normal fast path). Falls back to the
1099
+ * decoder registry ONLY when the failure category indicates the format
1100
+ * itself is the problem (`UNSUPPORTED_FORMAT`, `DECODER`) — never for
1101
+ * `NETWORK` (404/500/CORS/interrupted), `ABORTED`, or `PERMISSION`, where
1102
+ * retrying via a decoder would just repeat the same failing request
1103
+ * (or a fresh one to the same broken URL) for no benefit. This matters
1104
+ * because `canPlayType()`/extension/mime are only ever a hint and can be
1105
+ * wrong (a mislabeled or fake extension), so the decision to fall back
1106
+ * has to be made from the real failure, not guessed upfront.
1107
+ */
1108
+ async resolveEngine(source, signal) {
1109
+ const native = this.engineFactory.create(this.kind, (fn) => this.zone.runOutsideAngular(fn), this.preload);
1110
+ try {
1111
+ await native.load(source, signal);
1112
+ return native;
1113
+ }
1114
+ catch (nativeErr) {
1115
+ native.destroy();
1116
+ if (signal.aborted)
1117
+ throw nativeErr; // deliberate cancellation, not a format failure — don't probe decoders
1118
+ if (!this.shouldFallbackToDecoder(nativeErr))
1119
+ throw nativeErr;
1120
+ const decoder = await this.decoderRegistry.findDecoder(source, signal);
1121
+ if (!decoder)
1122
+ throw nativeErr;
1123
+ // The decoder returns an engine that's already loaded internally
1124
+ // (possibly against a transcoded blob, not the original source) — it
1125
+ // must NOT have `.load(source)` called on it again afterwards.
1126
+ return decoder.createEngine(source, signal);
1127
+ }
1128
+ }
1129
+ shouldFallbackToDecoder(error) {
1130
+ return error instanceof NgxMediaError && DECODER_FALLBACK_CATEGORIES.has(error.category);
1131
+ }
1132
+ async play() {
1133
+ if (!this.engine)
1134
+ return;
1135
+ try {
1136
+ await this.engine.play();
1137
+ this.mediaSession.setPlaybackState('playing');
1138
+ }
1139
+ catch (err) {
1140
+ this._error.set(err instanceof NgxMediaError ? err : new NgxMediaError('UNKNOWN', 'Play failed.', err));
1141
+ }
1142
+ }
1143
+ pause() {
1144
+ if (!this.engine)
1145
+ return;
1146
+ this.engine.pause();
1147
+ this.mediaSession.setPlaybackState('paused');
1148
+ }
1149
+ togglePlayPause() {
1150
+ if (this.state().playback === 'playing')
1151
+ this.pause();
1152
+ else
1153
+ void this.play();
1154
+ }
1155
+ seek(time) {
1156
+ this.engine?.seek(time);
1157
+ }
1158
+ skip(deltaSeconds) {
1159
+ const t = this.state().currentTime + deltaSeconds;
1160
+ this.seek(Math.min(Math.max(t, 0), this.state().duration || t));
1161
+ }
1162
+ setVolume(volume) {
1163
+ this.engine?.setVolume(volume);
1164
+ }
1165
+ setMuted(muted) {
1166
+ this.engine?.setMuted(muted);
1167
+ }
1168
+ setPlaybackRate(rate) {
1169
+ this.engine?.setPlaybackRate(rate);
1170
+ }
1171
+ async previous() {
1172
+ const list = this._playlist();
1173
+ if (list.length === 0)
1174
+ return;
1175
+ const next = (this._currentIndex() - 1 + list.length) % list.length;
1176
+ this._currentIndex.set(next);
1177
+ await this.loadCurrent(true);
1178
+ }
1179
+ async next() {
1180
+ const list = this._playlist();
1181
+ if (list.length === 0)
1182
+ return;
1183
+ const next = (this._currentIndex() + 1) % list.length;
1184
+ this._currentIndex.set(next);
1185
+ await this.loadCurrent(true);
1186
+ }
1187
+ async selectIndex(index) {
1188
+ if (index < 0 || index >= this._playlist().length)
1189
+ return;
1190
+ // The facade owns the active engine. If the requested index is already
1191
+ // backed by an engine, selecting it again is a no-op even if a custom
1192
+ // engine reports an unusual/implementation-specific state. An explicit
1193
+ // reloadCurrent() remains the opt-in way to restart the same source.
1194
+ const alreadyOnIt = index === this._currentIndex() && this.engine !== null;
1195
+ if (alreadyOnIt) {
1196
+ // Same track, already loaded — selecting it again must not trigger a
1197
+ // fresh network load. Use `reloadCurrent()` for an explicit restart.
1198
+ return;
1199
+ }
1200
+ this._currentIndex.set(index);
1201
+ await this.loadCurrent(true);
1202
+ }
1203
+ /** Lazily creates the Web Audio graph — never touched unless a consumer asks for it. Always tied to the CURRENT engine's element; invalidated automatically on every engine swap (see `loadCurrent`). */
1204
+ getWebAudioGraph() {
1205
+ const el = this.mediaElement();
1206
+ if (!el)
1207
+ throw new NgxMediaError('MEDIA', 'No active media element to attach a Web Audio graph to.');
1208
+ if (!this.webAudio)
1209
+ this.webAudio = new NgxWebAudioGraph(el);
1210
+ return this.webAudio;
1211
+ }
1212
+ bindMediaSession() {
1213
+ this.mediaSession.bind({
1214
+ play: () => void this.play(),
1215
+ pause: () => this.pause(),
1216
+ previous: () => void this.previous(),
1217
+ next: () => void this.next(),
1218
+ seekBackward: (s) => this.skip(-s),
1219
+ seekForward: (s) => this.skip(s),
1220
+ });
1221
+ }
1222
+ destroy() {
1223
+ this.loadAbort?.abort();
1224
+ this.loadGeneration++;
1225
+ this.mediaSession.unbind();
1226
+ this.webAudio?.destroy();
1227
+ this.engine?.destroy();
1228
+ this._engine.set(null);
1229
+ }
1230
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaFacade, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1231
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaFacade });
1232
+ }
1233
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaFacade, decorators: [{
1234
+ type: Injectable
1235
+ }], ctorParameters: () => [] });
1236
+
1237
+ class NgxMediaControl {
1238
+ // --- configuration ---------------------------------------------------
1239
+ /**
1240
+ * `'audio'` renders an audio-style control surface (no video frame).
1241
+ * `'video'` renders a video frame with fullscreen/PiP controls.
1242
+ * `'both'` renders the video frame but degrades gracefully to audio-only
1243
+ * controls when the active source has no video track.
1244
+ */
1245
+ type = input('audio', /* @ts-ignore */
1246
+ ...(ngDevMode ? [{ debugName: "type" }] : /* istanbul ignore next */ []));
1247
+ /**
1248
+ * Poster precedence: this input, when set, ALWAYS wins over a per-source
1249
+ * `poster` (useful for a fixed placeholder while sources vary). Leave it
1250
+ * unset to let each `NgxMediaSource.poster` take effect per track, and
1251
+ * clearing either one correctly clears the poster actually applied (see
1252
+ * the mount effect below) instead of leaving a stale image behind.
1253
+ */
1254
+ poster = input(undefined, /* @ts-ignore */
1255
+ ...(ngDevMode ? [{ debugName: "poster" }] : /* istanbul ignore next */ []));
1256
+ // --- inputs preserved verbatim from NgxAudioControl -------------------
1257
+ showList = input(true, /* @ts-ignore */
1258
+ ...(ngDevMode ? [{ debugName: "showList" }] : /* istanbul ignore next */ []));
1259
+ download = input(false, /* @ts-ignore */
1260
+ ...(ngDevMode ? [{ debugName: "download" }] : /* istanbul ignore next */ []));
1261
+ showFileName = input(true, /* @ts-ignore */
1262
+ ...(ngDevMode ? [{ debugName: "showFileName" }] : /* istanbul ignore next */ []));
1263
+ showSpeed = input(true, /* @ts-ignore */
1264
+ ...(ngDevMode ? [{ debugName: "showSpeed" }] : /* istanbul ignore next */ []));
1265
+ showVolume = input(true, /* @ts-ignore */
1266
+ ...(ngDevMode ? [{ debugName: "showVolume" }] : /* istanbul ignore next */ []));
1267
+ linear = input(false, /* @ts-ignore */
1268
+ ...(ngDevMode ? [{ debugName: "linear" }] : /* istanbul ignore next */ []));
1269
+ preload = input('metadata', /* @ts-ignore */
1270
+ ...(ngDevMode ? [{ debugName: "preload" }] : /* istanbul ignore next */ []));
1271
+ /**
1272
+ * IMPORTANT, and different from earlier versions of this component: a
1273
+ * native `<audio>`/`<video src>` fetch cannot carry arbitrary request
1274
+ * headers — that's a browser platform limitation, not something this
1275
+ * library can paper over without fetching the whole file into a `Blob`
1276
+ * first (which defeats streaming/range-requests for anything but small
1277
+ * clips, and was explicitly the wrong default per the library's
1278
+ * performance goals). So `fetchHeaders` is applied as the DEFAULT
1279
+ * `requestHeaders` for any resolved source that doesn't specify its own
1280
+ * (see `resolvedSources` below) — it takes effect for sources that end
1281
+ * up on a decoder/MSE backend (which do fetch through
1282
+ * `NGX_MEDIA_REQUEST_HANDLER` and can carry headers), and has no effect
1283
+ * on plain natively-playable sources, which never go through a fetch at
1284
+ * all. Set headers per-source via `NgxMediaSource.requestHeaders`
1285
+ * whenever different tracks need different auth.
1286
+ */
1287
+ fetchHeaders = input(undefined, /* @ts-ignore */
1288
+ ...(ngDevMode ? [{ debugName: "fetchHeaders" }] : /* istanbul ignore next */ []));
1289
+ /** Legacy simple form: bare URLs, one per playlist entry. Prefer `sources` for titles/artwork/per-track headers. */
1290
+ fileList = input(/* @ts-ignore */
1291
+ ...(ngDevMode ? [undefined, { debugName: "fileList" }] : /* istanbul ignore next */ []));
1292
+ /** Preferred over `fileList`: full `NgxMediaSource` objects. */
1293
+ sources = input(/* @ts-ignore */
1294
+ ...(ngDevMode ? [undefined, { debugName: "sources" }] : /* istanbul ignore next */ []));
1295
+ // --- outputs -----------------------------------------------------------
1296
+ trackChange = output();
1297
+ error = output();
1298
+ doc = inject(DOCUMENT);
1299
+ destroyRef = inject(DestroyRef);
1300
+ facade = inject(NgxMediaFacade);
1301
+ containerRef = viewChild.required('container');
1302
+ videoHostRef = viewChild('videoHost', /* @ts-ignore */
1303
+ ...(ngDevMode ? [{ debugName: "videoHostRef" }] : /* istanbul ignore next */ []));
1304
+ downloading = signal(false, /* @ts-ignore */
1305
+ ...(ngDevMode ? [{ debugName: "downloading" }] : /* istanbul ignore next */ []));
1306
+ togglePlayList = signal(false, /* @ts-ignore */
1307
+ ...(ngDevMode ? [{ debugName: "togglePlayList" }] : /* istanbul ignore next */ []));
1308
+ isFullscreen = signal(false, /* @ts-ignore */
1309
+ ...(ngDevMode ? [{ debugName: "isFullscreen" }] : /* istanbul ignore next */ []));
1310
+ /** Local seek preview — see the seek-slider handlers below for why the slider never binds `state().currentTime` directly while the user is dragging it. */
1311
+ isSeeking = signal(false, /* @ts-ignore */
1312
+ ...(ngDevMode ? [{ debugName: "isSeeking" }] : /* istanbul ignore next */ []));
1313
+ seekPreviewTime = signal(0, /* @ts-ignore */
1314
+ ...(ngDevMode ? [{ debugName: "seekPreviewTime" }] : /* istanbul ignore next */ []));
1315
+ state = this.facade.state;
1316
+ playlist = this.facade.playlist;
1317
+ currentIndex = this.facade.currentIndex;
1318
+ currentSource = this.facade.currentSource;
1319
+ playerError = this.facade.error;
1320
+ effectiveKind = computed(() => this.type() === 'audio' ? 'audio' : 'video', /* @ts-ignore */
1321
+ ...(ngDevMode ? [{ debugName: "effectiveKind" }] : /* istanbul ignore next */ []));
1322
+ displaySeekTime = computed(() => this.isSeeking() ? this.seekPreviewTime() : this.state().currentTime, /* @ts-ignore */
1323
+ ...(ngDevMode ? [{ debugName: "displaySeekTime" }] : /* istanbul ignore next */ []));
1324
+ currentTimeDisplay = computed(() => formatTime(this.displaySeekTime()), /* @ts-ignore */
1325
+ ...(ngDevMode ? [{ debugName: "currentTimeDisplay" }] : /* istanbul ignore next */ []));
1326
+ totalTimeDisplay = computed(() => formatTime(this.state().duration), /* @ts-ignore */
1327
+ ...(ngDevMode ? [{ debugName: "totalTimeDisplay" }] : /* istanbul ignore next */ []));
1328
+ speedDisplay = computed(() => `${this.state().playbackRate.toFixed(2)}x`, /* @ts-ignore */
1329
+ ...(ngDevMode ? [{ debugName: "speedDisplay" }] : /* istanbul ignore next */ []));
1330
+ fileName = computed(() => {
1331
+ const source = this.currentSource();
1332
+ return source ? extractMediaFileName(source) : '';
1333
+ }, /* @ts-ignore */
1334
+ ...(ngDevMode ? [{ debugName: "fileName" }] : /* istanbul ignore next */ []));
1335
+ isPlaying = computed(() => this.state().playback === 'playing', /* @ts-ignore */
1336
+ ...(ngDevMode ? [{ debugName: "isPlaying" }] : /* istanbul ignore next */ []));
1337
+ isBuffering = computed(() => this.state().playback === 'buffering' || this.state().playback === 'loading', /* @ts-ignore */
1338
+ ...(ngDevMode ? [{ debugName: "isBuffering" }] : /* istanbul ignore next */ []));
1339
+ hasLoadError = computed(() => this.state().playback === 'error', /* @ts-ignore */
1340
+ ...(ngDevMode ? [{ debugName: "hasLoadError" }] : /* istanbul ignore next */ []));
1341
+ errorMessage = computed(() => this.playerError()?.message ?? '', /* @ts-ignore */
1342
+ ...(ngDevMode ? [{ debugName: "errorMessage" }] : /* istanbul ignore next */ []));
1343
+ canFullscreen = computed(() => this.effectiveKind() === 'video' && hasFullscreen(), /* @ts-ignore */
1344
+ ...(ngDevMode ? [{ debugName: "canFullscreen" }] : /* istanbul ignore next */ []));
1345
+ canPip = computed(() => this.effectiveKind() === 'video' && hasPictureInPicture(), /* @ts-ignore */
1346
+ ...(ngDevMode ? [{ debugName: "canPip" }] : /* istanbul ignore next */ []));
1347
+ /**
1348
+ * Derived from `sources()` OR `fileList()` (whichever the consumer used),
1349
+ * memoized by CONTENT rather than reference (`equal: mediaSourcesEqual`).
1350
+ * Angular's `input()` signal only compares by reference — a parent that
1351
+ * binds an inline literal, e.g. `[sources]="[{...}]"`, or otherwise
1352
+ * re-derives an equivalent array on every change-detection pass, produces
1353
+ * a *new* array every tick. Without the custom `equal`, the effect below
1354
+ * would see "a new value" on every tick and reload media in a loop. A
1355
+ * content-equal `computed` keeps returning its previous, `===`-stable
1356
+ * value whenever the new content is equivalent, so the effect below
1357
+ * simply doesn't re-run.
1358
+ */
1359
+ resolvedSources = computed(() => {
1360
+ const globalHeaders = this.fetchHeaders();
1361
+ const explicit = this.sources();
1362
+ if (explicit) {
1363
+ return explicit.map((m) => ({
1364
+ ...m,
1365
+ title: extractMediaFileName(m),
1366
+ requestHeaders: m.requestHeaders ?? globalHeaders,
1367
+ }));
1368
+ }
1369
+ const simple = this.fileList() ?? [];
1370
+ return simple.map((src) => ({
1371
+ src,
1372
+ title: extractMediaFileName({ src }),
1373
+ requestHeaders: globalHeaders,
1374
+ }));
1375
+ }, { ...(ngDevMode ? { debugName: "resolvedSources" } : /* istanbul ignore next */ {}), equal: mediaSourcesEqual });
1376
+ /** Tracks what's actually mounted in `videoHostRef`, so the mount effect only touches the DOM (and re-registers listeners) when the element itself changes, not on every unrelated signal read. */
1377
+ mountedElement = null;
1378
+ dblClickHandler = null;
1379
+ downloadResetTimeout = null;
1380
+ constructor() {
1381
+ effect(() => this.facade.configure(this.effectiveKind()));
1382
+ effect(() => this.facade.setPreload(this.preload()));
1383
+ // Rebuild the playlist whenever the consumer swaps sources to genuinely
1384
+ // different content (see `resolvedSources` above for why this doesn't
1385
+ // fire on every change-detection tick).
1386
+ effect(() => {
1387
+ this.facade.setPlaylist(this.resolvedSources(), 0, true);
1388
+ });
1389
+ effect(() => {
1390
+ const err = this.playerError();
1391
+ if (err)
1392
+ this.error.emit(err.message);
1393
+ });
1394
+ effect(() => {
1395
+ const source = this.currentSource();
1396
+ if (source)
1397
+ this.trackChange.emit(source);
1398
+ });
1399
+ if (isBrowser()) {
1400
+ const onFsChange = () => this.isFullscreen.set(!!this.doc.fullscreenElement);
1401
+ this.doc.addEventListener('fullscreenchange', onFsChange);
1402
+ this.destroyRef.onDestroy(() => this.doc.removeEventListener('fullscreenchange', onFsChange));
1403
+ }
1404
+ // The engine owns/creates its own <audio>/<video> element (so the UI
1405
+ // never has to know how playback happens). For video mode we mount that
1406
+ // element into the template's host div — but ONLY when the element
1407
+ // itself actually changed (a new engine was created). Poster updates,
1408
+ // or this effect simply re-running for an unrelated reason, must not
1409
+ // re-create the dblclick listener or churn the DOM by detaching and
1410
+ // reattaching an element that's already correctly mounted.
1411
+ effect(() => {
1412
+ const el = this.facade.mediaElement();
1413
+ const host = this.videoHostRef()?.nativeElement;
1414
+ const isVideo = this.effectiveKind() === 'video';
1415
+ const effectivePoster = this.poster() ?? this.currentSource()?.poster;
1416
+ if (!host || !isVideo || !el) {
1417
+ this.detachMountedElement(host);
1418
+ return;
1419
+ }
1420
+ if (el !== this.mountedElement) {
1421
+ this.detachMountedElement(host);
1422
+ host.replaceChildren();
1423
+ el.classList.add('ngx-media-video-element');
1424
+ el.setAttribute('playsinline', '');
1425
+ this.dblClickHandler = () => void this.toggleFullscreen();
1426
+ el.addEventListener('dblclick', this.dblClickHandler);
1427
+ host.appendChild(el);
1428
+ this.mountedElement = el;
1429
+ }
1430
+ // Property update only — never re-creates the listener or touches the DOM tree.
1431
+ el.poster = effectivePoster ?? '';
1432
+ });
1433
+ this.destroyRef.onDestroy(() => {
1434
+ this.detachMountedElement(null);
1435
+ if (this.downloadResetTimeout)
1436
+ clearTimeout(this.downloadResetTimeout);
1437
+ });
1438
+ }
1439
+ detachMountedElement(host) {
1440
+ if (this.mountedElement && this.dblClickHandler) {
1441
+ this.mountedElement.removeEventListener('dblclick', this.dblClickHandler);
1442
+ }
1443
+ this.mountedElement = null;
1444
+ this.dblClickHandler = null;
1445
+ host?.replaceChildren();
1446
+ }
1447
+ // --- transport controls (same method names as the legacy component) ---
1448
+ playPause() {
1449
+ this.facade.togglePlayPause();
1450
+ }
1451
+ play() {
1452
+ void this.facade.play();
1453
+ }
1454
+ stop() {
1455
+ this.facade.pause();
1456
+ }
1457
+ muteUnmute() {
1458
+ this.facade.setMuted(!this.state().muted);
1459
+ }
1460
+ /**
1461
+ * Seek strategy: dragging the slider only updates a local preview
1462
+ * (`seekPreviewTime`) — no `facade.seek()` call, so no HTTP range
1463
+ * request or media-pipeline seek happens per pixel of drag. The actual
1464
+ * seek fires once, on `change` (`onSeekCommit`), which browsers fire
1465
+ * both on pointer-release-after-drag AND after each discrete keyboard
1466
+ * adjustment — so keyboard accessibility (arrow keys) still seeks
1467
+ * immediately per keypress, it's only continuous dragging that's
1468
+ * coalesced into a single request.
1469
+ */
1470
+ onSeekInput(ev) {
1471
+ const value = +ev.target.value;
1472
+ this.isSeeking.set(true);
1473
+ this.seekPreviewTime.set(value);
1474
+ }
1475
+ onSeekCommit(ev) {
1476
+ const value = +ev.target.value;
1477
+ this.isSeeking.set(false);
1478
+ this.facade.seek(value);
1479
+ }
1480
+ skipTrack(seconds) {
1481
+ this.facade.skip(seconds);
1482
+ }
1483
+ increaseSpeed() {
1484
+ this.facade.setPlaybackRate(Math.max(0.5, this.state().playbackRate + 0.25));
1485
+ }
1486
+ decreaseSpeed() {
1487
+ this.facade.setPlaybackRate(Math.max(0.5, this.state().playbackRate - 0.25));
1488
+ }
1489
+ changeVolume(ev) {
1490
+ this.facade.setVolume(+ev.target.value);
1491
+ }
1492
+ previous() {
1493
+ void this.facade.previous();
1494
+ }
1495
+ next() {
1496
+ void this.facade.next();
1497
+ }
1498
+ onClickPlayList(index) {
1499
+ this.togglePlayList.set(false);
1500
+ void this.facade.selectIndex(index);
1501
+ }
1502
+ togglePlaylistPanel() {
1503
+ this.togglePlayList.update((v) => !v);
1504
+ }
1505
+ /** Force-reload the current track from scratch (e.g. a "retry" affordance after an error) rather than relying on selecting the same playlist entry, which deliberately does NOT reload an already-loaded track. */
1506
+ retryCurrent() {
1507
+ void this.facade.reloadCurrent();
1508
+ }
1509
+ downloadCurrentFile() {
1510
+ const source = this.currentSource();
1511
+ if (!source)
1512
+ return;
1513
+ const url = source.downloadUrl ?? (typeof source.src === 'string' ? source.src : undefined);
1514
+ if (!url)
1515
+ return;
1516
+ if (this.downloadResetTimeout)
1517
+ clearTimeout(this.downloadResetTimeout);
1518
+ this.downloading.set(true);
1519
+ const a = this.doc.createElement('a');
1520
+ a.style.display = 'none';
1521
+ a.href = url;
1522
+ a.download = source.downloadFileName ?? extractMediaFileName(source);
1523
+ this.doc.body.appendChild(a);
1524
+ a.click();
1525
+ a.remove();
1526
+ // The `download` attribute gives no completion signal (and for
1527
+ // cross-origin URLs, browsers may ignore it and navigate instead — see
1528
+ // README "Security limitations"). This timeout only resets the button's
1529
+ // busy state; it is not, and cannot be, a real completion check.
1530
+ this.downloadResetTimeout = setTimeout(() => {
1531
+ this.downloading.set(false);
1532
+ this.downloadResetTimeout = null;
1533
+ }, 1000);
1534
+ }
1535
+ // --- video-only capabilities --------------------------------------------
1536
+ async toggleFullscreen() {
1537
+ if (!this.canFullscreen())
1538
+ return;
1539
+ const el = this.containerRef().nativeElement;
1540
+ try {
1541
+ if (this.doc.fullscreenElement) {
1542
+ await this.doc.exitFullscreen();
1543
+ }
1544
+ else {
1545
+ await el.requestFullscreen();
1546
+ }
1547
+ }
1548
+ catch {
1549
+ // Fullscreen can be denied by the browser (e.g. no user gesture); fail silently, UI state stays accurate via the fullscreenchange listener.
1550
+ }
1551
+ }
1552
+ async togglePictureInPicture() {
1553
+ const el = this.facade.mediaElement();
1554
+ if (!el || !this.canPip())
1555
+ return;
1556
+ try {
1557
+ if (this.doc.pictureInPictureElement) {
1558
+ await this.doc.exitPictureInPicture();
1559
+ }
1560
+ else {
1561
+ await el.requestPictureInPicture();
1562
+ }
1563
+ }
1564
+ catch {
1565
+ // Not every video/browser combination supports PiP at call time; ignore.
1566
+ }
1567
+ }
1568
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaControl, deps: [], target: i0.ɵɵFactoryTarget.Component });
1569
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.4", type: NgxMediaControl, isStandalone: true, selector: "ngx-media-control", inputs: { type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, poster: { classPropertyName: "poster", publicName: "poster", isSignal: true, isRequired: false, transformFunction: null }, showList: { classPropertyName: "showList", publicName: "showList", isSignal: true, isRequired: false, transformFunction: null }, download: { classPropertyName: "download", publicName: "download", isSignal: true, isRequired: false, transformFunction: null }, showFileName: { classPropertyName: "showFileName", publicName: "showFileName", isSignal: true, isRequired: false, transformFunction: null }, showSpeed: { classPropertyName: "showSpeed", publicName: "showSpeed", isSignal: true, isRequired: false, transformFunction: null }, showVolume: { classPropertyName: "showVolume", publicName: "showVolume", isSignal: true, isRequired: false, transformFunction: null }, linear: { classPropertyName: "linear", publicName: "linear", isSignal: true, isRequired: false, transformFunction: null }, preload: { classPropertyName: "preload", publicName: "preload", isSignal: true, isRequired: false, transformFunction: null }, fetchHeaders: { classPropertyName: "fetchHeaders", publicName: "fetchHeaders", isSignal: true, isRequired: false, transformFunction: null }, fileList: { classPropertyName: "fileList", publicName: "fileList", isSignal: true, isRequired: false, transformFunction: null }, sources: { classPropertyName: "sources", publicName: "sources", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { trackChange: "trackChange", error: "error" }, providers: [NgxMediaFacade], viewQueries: [{ propertyName: "containerRef", first: true, predicate: ["container"], descendants: true, isSignal: true }, { propertyName: "videoHostRef", first: true, predicate: ["videoHost"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"media-player\" [class.linear]=\"linear()\" [class.video-mode]=\"effectiveKind() === 'video'\" #container>\n @if (showFileName()) {\n <span class=\"file-info\">{{ fileName() }}</span>\n }\n\n @if (effectiveKind() === 'video') {\n <div class=\"video-host\" #videoHost></div>\n }\n\n <div class=\"controls\">\n <div class=\"time-seeker\">\n <span>{{ currentTimeDisplay() }}</span>\n <input\n class=\"seek-slider\"\n type=\"range\"\n min=\"0\"\n [max]=\"state().duration || 0\"\n [value]=\"displaySeekTime()\"\n step=\".1\"\n (input)=\"onSeekInput($event)\"\n (change)=\"onSeekCommit($event)\" />\n <span>{{ totalTimeDisplay() }}</span>\n @if (isBuffering()) {\n <div class=\"simp-buffer\"></div>\n }\n </div>\n <div class=\"buttons\">\n <button title=\"skip track -10\" (click)=\"skipTrack(-10)\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M7 9l-3 -3l3 -3\"></path>\n <path d=\"M15.997 17.918a6.002 6.002 0 0 0 -.997 -11.918h-11\"></path>\n </svg>\n </button>\n\n @if (playlist().length > 1) {\n <button (click)=\"previous()\" title=\"previous\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M20 5v14l-12 -7z\" />\n <path d=\"M4 5l0 14\" />\n </svg>\n </button>\n }\n\n <button\n (click)=\"playPause()\"\n [title]=\"hasLoadError() ? 'Error on load media source' : isPlaying() ? 'pause' : 'play'\"\n [disabled]=\"hasLoadError()\">\n @if (!isPlaying()) {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z\" />\n </svg>\n } @else {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M4 7C4 5.58579 4 4.87868 4.43934 4.43934C4.87868 4 5.58579 4 7 4C8.41421 4 9.12132 4 9.56066 4.43934C10 4.87868 10 5.58579 10 7V17C10 18.4142 10 19.1213 9.56066 19.5607C9.12132 20 8.41421 20 7 20C5.58579 20 4.87868 20 4.43934 19.5607C4 19.1213 4 18.4142 4 17V7Z\" />\n <path\n d=\"M14 7C14 5.58579 14 4.87868 14.4393 4.43934C14.8787 4 15.5858 4 17 4C18.4142 4 19.1213 4 19.5607 4.43934C20 4.87868 20 5.58579 20 7V17C20 18.4142 20 19.1213 19.5607 19.5607C19.1213 20 18.4142 20 17 20C15.5858 20 14.8787 20 14.4393 19.5607C14 19.1213 14 18.4142 14 17V7Z\" />\n </svg>\n }\n </button>\n\n @if (playlist().length > 1) {\n <button (click)=\"next()\" title=\"next\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M4 5v14l12 -7z\" />\n <path d=\"M20 5l0 14\" />\n </svg>\n </button>\n }\n <button title=\"skip track +10\" (click)=\"skipTrack(10)\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M17 9l3 -3l-3 -3\" />\n <path d=\"M8 17.918a5.997 5.997 0 0 1 -5 -5.918a6 6 0 0 1 6 -6h11\" />\n </svg>\n </button>\n\n @if (showVolume()) {\n <div class=\"volume-control\">\n <button (click)=\"muteUnmute()\" title=\"mute volume\">\n @if (state().muted) {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M14 14.8135V9.18646C14 6.04126 14 4.46866 13.0747 4.0773C12.1494 3.68593 11.0603 4.79793 8.88232 7.02192C7.75439 8.17365 7.11085 8.42869 5.50604 8.42869C4.10257 8.42869 3.40084 8.42869 2.89675 8.77262C1.85035 9.48655 2.00852 10.882 2.00852 12C2.00852 13.118 1.85035 14.5134 2.89675 15.2274C3.40084 15.5713 4.10257 15.5713 5.50604 15.5713C7.11085 15.5713 7.75439 15.8264 8.88232 16.9781C11.0603 19.2021 12.1494 20.3141 13.0747 19.9227C14 19.5313 14 17.9587 14 14.8135Z\" />\n <path d=\"M18 10L22 14M18 14L22 10\" />\n </svg>\n } @else {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M14 14.8135V9.18646C14 6.04126 14 4.46866 13.0747 4.0773C12.1494 3.68593 11.0603 4.79793 8.88232 7.02192C7.75439 8.17365 7.11085 8.42869 5.50604 8.42869C4.10257 8.42869 3.40084 8.42869 2.89675 8.77262C1.85035 9.48655 2.00852 10.882 2.00852 12C2.00852 13.118 1.85035 14.5134 2.89675 15.2274C3.40084 15.5713 4.10257 15.5713 5.50604 15.5713C7.11085 15.5713 7.75439 15.8264 8.88232 16.9781C11.0603 19.2021 12.1494 20.3141 13.0747 19.9227C14 19.5313 14 17.9587 14 14.8135Z\" />\n <path d=\"M17 9C17.6254 9.81968 18 10.8634 18 12C18 13.1366 17.6254 14.1803 17 15\" />\n <path d=\"M20 7C21.2508 8.36613 22 10.1057 22 12C22 13.8943 21.2508 15.6339 20 17\" />\n </svg>\n }\n </button>\n <input\n class=\"volume-slider\"\n type=\"range\"\n min=\"0\"\n max=\"1\"\n step=\"0.01\"\n [value]=\"state().volume\"\n (input)=\"changeVolume($event)\" />\n </div>\n }\n @if (showSpeed()) {\n <div class=\"speed-control\">\n <button (click)=\"decreaseSpeed()\" title=\"decrease speed\">\n <svg viewBox=\"0 0 24 24\"><path d=\"M20 12L4 12\" /></svg>\n </button>\n <span class=\"speed-display\">{{ speedDisplay() }}</span>\n <button (click)=\"increaseSpeed()\" title=\"increase speed\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M12 4V20\" />\n <path d=\"M4 12H20\" />\n </svg>\n </button>\n </div>\n }\n\n @if (canFullscreen()) {\n <button (click)=\"toggleFullscreen()\" title=\"fullscreen\">\n <svg viewBox=\"0 0 24 24\"><path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\" /></svg>\n </button>\n }\n @if (canPip()) {\n <button (click)=\"togglePictureInPicture()\" title=\"picture in picture\">\n <svg viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"14\" rx=\"1\"></rect><rect x=\"12\" y=\"11\" width=\"7\" height=\"5\" rx=\"1\"></rect></svg>\n </button>\n }\n\n <div class=\"spacer\"></div>\n @if (showList()) {\n <button (click)=\"togglePlaylistPanel()\" title=\"Play list\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M2 3L20 3\" />\n <path d=\"M2 10L15 10\" />\n <path d=\"M2 17L9 17\" />\n </svg>\n </button>\n }\n @if (download()) {\n <button (click)=\"downloadCurrentFile()\" title=\"download\" [disabled]=\"downloading()\">\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M12 14.5L12 4.5M12 14.5C11.2998 14.5 9.99153 12.5057 9.5 12M12 14.5C12.7002 14.5 14.0085 12.5057 14.5 12\" />\n <path d=\"M20 16.5C20 18.982 19.482 19.5 17 19.5H7C4.518 19.5 4 18.982 4 16.5\" />\n </svg>\n </button>\n }\n </div>\n </div>\n @if (errorMessage()) {\n <div class=\"error-message\">\n {{ errorMessage() }}\n <button (click)=\"retryCurrent()\" title=\"retry\">retry</button>\n </div>\n }\n</div>\n\n@if (togglePlayList()) {\n <div class=\"play-list\" [class.linear]=\"linear()\">\n @if (playlist().length === 0) {\n <div class=\"list-item disabled\">No any record</div>\n }\n @for (item of playlist(); track $index; let i = $index) {\n <div class=\"list-item\" [class.active]=\"i === currentIndex()\" (click)=\"onClickPlayList(i)\">\n {{ item.title }}\n </div>\n }\n </div>\n}\n", styles: [":host{display:inline-block;width:500px;--ngx-audio-control-bg: light-dark(#fff, #222);--ngx-audio-control-text: light-dark(#000, #f4f5f7);--ngx-audio-control-icon: light-dark(#183153, #009688);--ngx-audio-control-border: light-dark(#e5e5e5, #474747);--ngx-audio-control-disable: light-dark(#e5e5e5, #4f4e4e);--ngx-audio-control-slider: light-dark(#ddd, #00968842);--ngx-audio-control-primary: light-dark(#007bff, #009688);--ngx-audio-control-primary-fg: light-dark(#f8f8f8, #f8f8f8);--ngx-audio-control-shadow: light-dark(#0000006e, #0000006e);--ngx-audio-control-error: light-dark(#e40505, #ff0000d3)}svg{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}.media-player,.play-list{background-color:var(--ngx-audio-control-bg);border-radius:10px;padding:10px;box-shadow:0 0 6px 0 var(--ngx-audio-control-shadow);position:relative;overflow:hidden;direction:ltr;max-width:100%}.media-player.linear .controls,.play-list.linear .controls{display:flex;flex-wrap:wrap;flex-direction:row-reverse}.controls .time-seeker{display:flex;align-items:center;flex:1 0 0;min-width:200px}.controls .buttons{display:flex;align-items:center;flex-wrap:wrap}.error-message{color:var(--ngx-audio-control-error)}button{background-color:transparent;border:none;font-size:18px;padding:5px;width:32px;height:32px;border:1px solid var(--ngx-audio-control-border);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:6px 2px;color:var(--ngx-audio-control-icon)}button:not(:disabled){cursor:pointer}button:disabled{cursor:not-allowed;background-color:var(--ngx-audio-control-disable)}button:focus{outline:none}.seek-slider{-webkit-appearance:none;appearance:none;width:100%;height:5px;background-color:var(--ngx-audio-control-slider);outline:none;border-radius:5px;cursor:pointer}.seek-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:15px;height:15px;background-color:var(--ngx-audio-control-primary);border-radius:50%;cursor:pointer}.seek-slider::-moz-range-thumb{width:15px;height:15px;background-color:var(--ngx-audio-control-primary);border-radius:50%;cursor:pointer}.speed-control{display:flex;justify-content:space-between;align-items:center}.speed-display{width:30px;text-align:center;overflow:hidden;font-size:12px;font-family:sans-serif;font-weight:100}.volume-control{display:flex;align-items:center;flex:auto;min-width:100px;max-width:200px}.spacer{flex:auto}.volume-slider{margin-left:10px;width:10px;flex:auto}.play-list{margin:3px 0}.play-list .list-item{border-bottom:1px var(--ngx-audio-control-border) solid;padding:10px 15px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.play-list .list-item:hover:not(.disabled){cursor:pointer;background-color:#7a7a7a2f;color:var(--ngx-audio-control-text)}.play-list .list-item.active{background-color:var(--ngx-audio-control-primary);color:var(--ngx-audio-control-primary-fg)}.simp-buffer{animation:audio-progress 1s linear infinite;background-image:linear-gradient(-45deg,#000 25%,transparent 25%,transparent 50%,#000 50%,#000 75%,transparent 75%,transparent);background-repeat:repeat-x;background-size:25px 25px;color:transparent;width:100%;height:4px;position:absolute;bottom:0;left:0;right:0}@keyframes audio-progress{to{background-position:25px 0}}.file-info{overflow:hidden;text-overflow:ellipsis;width:100%;display:block}.media-player.video-mode .video-host{position:relative;width:100%;background:#000;aspect-ratio:16/9}.media-player.video-mode .video-host ::ng-deep .ngx-media-video-element{width:100%;height:100%;display:block}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1570
+ }
1571
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.4", ngImport: i0, type: NgxMediaControl, decorators: [{
1572
+ type: Component,
1573
+ args: [{ standalone: true, selector: 'ngx-media-control', imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, providers: [NgxMediaFacade], template: "<div class=\"media-player\" [class.linear]=\"linear()\" [class.video-mode]=\"effectiveKind() === 'video'\" #container>\n @if (showFileName()) {\n <span class=\"file-info\">{{ fileName() }}</span>\n }\n\n @if (effectiveKind() === 'video') {\n <div class=\"video-host\" #videoHost></div>\n }\n\n <div class=\"controls\">\n <div class=\"time-seeker\">\n <span>{{ currentTimeDisplay() }}</span>\n <input\n class=\"seek-slider\"\n type=\"range\"\n min=\"0\"\n [max]=\"state().duration || 0\"\n [value]=\"displaySeekTime()\"\n step=\".1\"\n (input)=\"onSeekInput($event)\"\n (change)=\"onSeekCommit($event)\" />\n <span>{{ totalTimeDisplay() }}</span>\n @if (isBuffering()) {\n <div class=\"simp-buffer\"></div>\n }\n </div>\n <div class=\"buttons\">\n <button title=\"skip track -10\" (click)=\"skipTrack(-10)\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M7 9l-3 -3l3 -3\"></path>\n <path d=\"M15.997 17.918a6.002 6.002 0 0 0 -.997 -11.918h-11\"></path>\n </svg>\n </button>\n\n @if (playlist().length > 1) {\n <button (click)=\"previous()\" title=\"previous\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M20 5v14l-12 -7z\" />\n <path d=\"M4 5l0 14\" />\n </svg>\n </button>\n }\n\n <button\n (click)=\"playPause()\"\n [title]=\"hasLoadError() ? 'Error on load media source' : isPlaying() ? 'pause' : 'play'\"\n [disabled]=\"hasLoadError()\">\n @if (!isPlaying()) {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z\" />\n </svg>\n } @else {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M4 7C4 5.58579 4 4.87868 4.43934 4.43934C4.87868 4 5.58579 4 7 4C8.41421 4 9.12132 4 9.56066 4.43934C10 4.87868 10 5.58579 10 7V17C10 18.4142 10 19.1213 9.56066 19.5607C9.12132 20 8.41421 20 7 20C5.58579 20 4.87868 20 4.43934 19.5607C4 19.1213 4 18.4142 4 17V7Z\" />\n <path\n d=\"M14 7C14 5.58579 14 4.87868 14.4393 4.43934C14.8787 4 15.5858 4 17 4C18.4142 4 19.1213 4 19.5607 4.43934C20 4.87868 20 5.58579 20 7V17C20 18.4142 20 19.1213 19.5607 19.5607C19.1213 20 18.4142 20 17 20C15.5858 20 14.8787 20 14.4393 19.5607C14 19.1213 14 18.4142 14 17V7Z\" />\n </svg>\n }\n </button>\n\n @if (playlist().length > 1) {\n <button (click)=\"next()\" title=\"next\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M4 5v14l12 -7z\" />\n <path d=\"M20 5l0 14\" />\n </svg>\n </button>\n }\n <button title=\"skip track +10\" (click)=\"skipTrack(10)\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M17 9l3 -3l-3 -3\" />\n <path d=\"M8 17.918a5.997 5.997 0 0 1 -5 -5.918a6 6 0 0 1 6 -6h11\" />\n </svg>\n </button>\n\n @if (showVolume()) {\n <div class=\"volume-control\">\n <button (click)=\"muteUnmute()\" title=\"mute volume\">\n @if (state().muted) {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M14 14.8135V9.18646C14 6.04126 14 4.46866 13.0747 4.0773C12.1494 3.68593 11.0603 4.79793 8.88232 7.02192C7.75439 8.17365 7.11085 8.42869 5.50604 8.42869C4.10257 8.42869 3.40084 8.42869 2.89675 8.77262C1.85035 9.48655 2.00852 10.882 2.00852 12C2.00852 13.118 1.85035 14.5134 2.89675 15.2274C3.40084 15.5713 4.10257 15.5713 5.50604 15.5713C7.11085 15.5713 7.75439 15.8264 8.88232 16.9781C11.0603 19.2021 12.1494 20.3141 13.0747 19.9227C14 19.5313 14 17.9587 14 14.8135Z\" />\n <path d=\"M18 10L22 14M18 14L22 10\" />\n </svg>\n } @else {\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M14 14.8135V9.18646C14 6.04126 14 4.46866 13.0747 4.0773C12.1494 3.68593 11.0603 4.79793 8.88232 7.02192C7.75439 8.17365 7.11085 8.42869 5.50604 8.42869C4.10257 8.42869 3.40084 8.42869 2.89675 8.77262C1.85035 9.48655 2.00852 10.882 2.00852 12C2.00852 13.118 1.85035 14.5134 2.89675 15.2274C3.40084 15.5713 4.10257 15.5713 5.50604 15.5713C7.11085 15.5713 7.75439 15.8264 8.88232 16.9781C11.0603 19.2021 12.1494 20.3141 13.0747 19.9227C14 19.5313 14 17.9587 14 14.8135Z\" />\n <path d=\"M17 9C17.6254 9.81968 18 10.8634 18 12C18 13.1366 17.6254 14.1803 17 15\" />\n <path d=\"M20 7C21.2508 8.36613 22 10.1057 22 12C22 13.8943 21.2508 15.6339 20 17\" />\n </svg>\n }\n </button>\n <input\n class=\"volume-slider\"\n type=\"range\"\n min=\"0\"\n max=\"1\"\n step=\"0.01\"\n [value]=\"state().volume\"\n (input)=\"changeVolume($event)\" />\n </div>\n }\n @if (showSpeed()) {\n <div class=\"speed-control\">\n <button (click)=\"decreaseSpeed()\" title=\"decrease speed\">\n <svg viewBox=\"0 0 24 24\"><path d=\"M20 12L4 12\" /></svg>\n </button>\n <span class=\"speed-display\">{{ speedDisplay() }}</span>\n <button (click)=\"increaseSpeed()\" title=\"increase speed\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M12 4V20\" />\n <path d=\"M4 12H20\" />\n </svg>\n </button>\n </div>\n }\n\n @if (canFullscreen()) {\n <button (click)=\"toggleFullscreen()\" title=\"fullscreen\">\n <svg viewBox=\"0 0 24 24\"><path d=\"M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5\" /></svg>\n </button>\n }\n @if (canPip()) {\n <button (click)=\"togglePictureInPicture()\" title=\"picture in picture\">\n <svg viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"14\" rx=\"1\"></rect><rect x=\"12\" y=\"11\" width=\"7\" height=\"5\" rx=\"1\"></rect></svg>\n </button>\n }\n\n <div class=\"spacer\"></div>\n @if (showList()) {\n <button (click)=\"togglePlaylistPanel()\" title=\"Play list\">\n <svg viewBox=\"0 0 24 24\">\n <path d=\"M2 3L20 3\" />\n <path d=\"M2 10L15 10\" />\n <path d=\"M2 17L9 17\" />\n </svg>\n </button>\n }\n @if (download()) {\n <button (click)=\"downloadCurrentFile()\" title=\"download\" [disabled]=\"downloading()\">\n <svg viewBox=\"0 0 24 24\">\n <path\n d=\"M12 14.5L12 4.5M12 14.5C11.2998 14.5 9.99153 12.5057 9.5 12M12 14.5C12.7002 14.5 14.0085 12.5057 14.5 12\" />\n <path d=\"M20 16.5C20 18.982 19.482 19.5 17 19.5H7C4.518 19.5 4 18.982 4 16.5\" />\n </svg>\n </button>\n }\n </div>\n </div>\n @if (errorMessage()) {\n <div class=\"error-message\">\n {{ errorMessage() }}\n <button (click)=\"retryCurrent()\" title=\"retry\">retry</button>\n </div>\n }\n</div>\n\n@if (togglePlayList()) {\n <div class=\"play-list\" [class.linear]=\"linear()\">\n @if (playlist().length === 0) {\n <div class=\"list-item disabled\">No any record</div>\n }\n @for (item of playlist(); track $index; let i = $index) {\n <div class=\"list-item\" [class.active]=\"i === currentIndex()\" (click)=\"onClickPlayList(i)\">\n {{ item.title }}\n </div>\n }\n </div>\n}\n", styles: [":host{display:inline-block;width:500px;--ngx-audio-control-bg: light-dark(#fff, #222);--ngx-audio-control-text: light-dark(#000, #f4f5f7);--ngx-audio-control-icon: light-dark(#183153, #009688);--ngx-audio-control-border: light-dark(#e5e5e5, #474747);--ngx-audio-control-disable: light-dark(#e5e5e5, #4f4e4e);--ngx-audio-control-slider: light-dark(#ddd, #00968842);--ngx-audio-control-primary: light-dark(#007bff, #009688);--ngx-audio-control-primary-fg: light-dark(#f8f8f8, #f8f8f8);--ngx-audio-control-shadow: light-dark(#0000006e, #0000006e);--ngx-audio-control-error: light-dark(#e40505, #ff0000d3)}svg{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}.media-player,.play-list{background-color:var(--ngx-audio-control-bg);border-radius:10px;padding:10px;box-shadow:0 0 6px 0 var(--ngx-audio-control-shadow);position:relative;overflow:hidden;direction:ltr;max-width:100%}.media-player.linear .controls,.play-list.linear .controls{display:flex;flex-wrap:wrap;flex-direction:row-reverse}.controls .time-seeker{display:flex;align-items:center;flex:1 0 0;min-width:200px}.controls .buttons{display:flex;align-items:center;flex-wrap:wrap}.error-message{color:var(--ngx-audio-control-error)}button{background-color:transparent;border:none;font-size:18px;padding:5px;width:32px;height:32px;border:1px solid var(--ngx-audio-control-border);border-radius:50%;display:flex;align-items:center;justify-content:center;margin:6px 2px;color:var(--ngx-audio-control-icon)}button:not(:disabled){cursor:pointer}button:disabled{cursor:not-allowed;background-color:var(--ngx-audio-control-disable)}button:focus{outline:none}.seek-slider{-webkit-appearance:none;appearance:none;width:100%;height:5px;background-color:var(--ngx-audio-control-slider);outline:none;border-radius:5px;cursor:pointer}.seek-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:15px;height:15px;background-color:var(--ngx-audio-control-primary);border-radius:50%;cursor:pointer}.seek-slider::-moz-range-thumb{width:15px;height:15px;background-color:var(--ngx-audio-control-primary);border-radius:50%;cursor:pointer}.speed-control{display:flex;justify-content:space-between;align-items:center}.speed-display{width:30px;text-align:center;overflow:hidden;font-size:12px;font-family:sans-serif;font-weight:100}.volume-control{display:flex;align-items:center;flex:auto;min-width:100px;max-width:200px}.spacer{flex:auto}.volume-slider{margin-left:10px;width:10px;flex:auto}.play-list{margin:3px 0}.play-list .list-item{border-bottom:1px var(--ngx-audio-control-border) solid;padding:10px 15px;text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.play-list .list-item:hover:not(.disabled){cursor:pointer;background-color:#7a7a7a2f;color:var(--ngx-audio-control-text)}.play-list .list-item.active{background-color:var(--ngx-audio-control-primary);color:var(--ngx-audio-control-primary-fg)}.simp-buffer{animation:audio-progress 1s linear infinite;background-image:linear-gradient(-45deg,#000 25%,transparent 25%,transparent 50%,#000 50%,#000 75%,transparent 75%,transparent);background-repeat:repeat-x;background-size:25px 25px;color:transparent;width:100%;height:4px;position:absolute;bottom:0;left:0;right:0}@keyframes audio-progress{to{background-position:25px 0}}.file-info{overflow:hidden;text-overflow:ellipsis;width:100%;display:block}.media-player.video-mode .video-host{position:relative;width:100%;background:#000;aspect-ratio:16/9}.media-player.video-mode .video-host ::ng-deep .ngx-media-video-element{width:100%;height:100%;display:block}\n"] }]
1574
+ }], ctorParameters: () => [], propDecorators: { type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], poster: [{ type: i0.Input, args: [{ isSignal: true, alias: "poster", required: false }] }], showList: [{ type: i0.Input, args: [{ isSignal: true, alias: "showList", required: false }] }], download: [{ type: i0.Input, args: [{ isSignal: true, alias: "download", required: false }] }], showFileName: [{ type: i0.Input, args: [{ isSignal: true, alias: "showFileName", required: false }] }], showSpeed: [{ type: i0.Input, args: [{ isSignal: true, alias: "showSpeed", required: false }] }], showVolume: [{ type: i0.Input, args: [{ isSignal: true, alias: "showVolume", required: false }] }], linear: [{ type: i0.Input, args: [{ isSignal: true, alias: "linear", required: false }] }], preload: [{ type: i0.Input, args: [{ isSignal: true, alias: "preload", required: false }] }], fetchHeaders: [{ type: i0.Input, args: [{ isSignal: true, alias: "fetchHeaders", required: false }] }], fileList: [{ type: i0.Input, args: [{ isSignal: true, alias: "fileList", required: false }] }], sources: [{ type: i0.Input, args: [{ isSignal: true, alias: "sources", required: false }] }], trackChange: [{ type: i0.Output, args: ["trackChange"] }], error: [{ type: i0.Output, args: ["error"] }], containerRef: [{ type: i0.ViewChild, args: ['container', { isSignal: true }] }], videoHostRef: [{ type: i0.ViewChild, args: ['videoHost', { isSignal: true }] }] } });
1575
+
1576
+ /**
1577
+ * Base class for WASM-backed decoders. It does the two things every such
1578
+ * decoder needs regardless of which codec module it wraps:
1579
+ *
1580
+ * 1. Lazily `import()`s the codec module on first real use, in a Web Worker
1581
+ * when a worker factory is supplied, so the (typically large) WASM binary
1582
+ * never enters the main bundle or the main thread's JS heap unless a
1583
+ * source actually needs it.
1584
+ * 2. Provides a cancellable, disposable lifecycle (`AbortSignal`-aware load,
1585
+ * `destroy()` terminates the worker / frees the WASM instance) that is
1586
+ * safe under overlap: a `destroy()` that lands while `loadModule()` is
1587
+ * still pending must not let that pending load "resurrect" a module
1588
+ * reference for a decoder instance that's already gone, and must not
1589
+ * let a subsequent `createEngine()` call kick off a SECOND concurrent
1590
+ * module load while the first is still in flight.
1591
+ *
1592
+ * This class intentionally does NOT ship a real codec — bundling e.g.
1593
+ * `@ffmpeg/ffmpeg` here would violate "no WASM in the default bundle" for
1594
+ * every consumer, including ones who never touch this file. Concrete
1595
+ * decoders (FLAC, WMA, a consumer's own 3GP decoder, ...) extend this and
1596
+ * supply `loadModule` + `buildEngine`.
1597
+ */
1598
+ class NgxWasmDecoderBase {
1599
+ priority = 0;
1600
+ worker = null;
1601
+ modulePromise = null;
1602
+ /** Bumped by every `destroy()`; lets an in-flight `loadModule()`/`createEngine()` notice it's talking to a dead instance once it resolves. */
1603
+ generation = 0;
1604
+ async createEngine(source, signal) {
1605
+ const myGeneration = this.generation;
1606
+ const module = await this.ensureModuleLoaded(myGeneration);
1607
+ if (signal?.aborted)
1608
+ throw new NgxMediaError('ABORTED', `${this.id}: decode cancelled before start.`);
1609
+ if (myGeneration !== this.generation) {
1610
+ throw new NgxMediaError('ABORTED', `${this.id}: decoder was destroyed while its module was loading.`);
1611
+ }
1612
+ if (this.createWorker && !this.worker) {
1613
+ this.worker = this.createWorker();
1614
+ }
1615
+ try {
1616
+ return await this.buildEngine(source, module, this.worker, signal);
1617
+ }
1618
+ catch (err) {
1619
+ // Preserve a category the caller already established (e.g. NETWORK
1620
+ // from fetching the source, ABORTED from a cancelled transcode,
1621
+ // UNSUPPORTED_FORMAT from a codec check) — only genuinely unknown
1622
+ // failures get relabeled as DECODER.
1623
+ if (err instanceof NgxMediaError)
1624
+ throw err;
1625
+ throw new NgxMediaError('DECODER', `${this.id}: failed to build a playable engine.`, err);
1626
+ }
1627
+ }
1628
+ ensureModuleLoaded(myGeneration) {
1629
+ if (!this.modulePromise) {
1630
+ this.modulePromise = this.loadModule().catch((err) => {
1631
+ if (myGeneration === this.generation)
1632
+ this.modulePromise = null; // allow retry on next call rather than caching a permanent failure
1633
+ throw new NgxMediaError('DECODER', `${this.id}: WASM module failed to load.`, err);
1634
+ });
1635
+ }
1636
+ // Share the in-flight promise across concurrent createEngine() callers
1637
+ // instead of starting a second module load; but re-check generation
1638
+ // once it resolves, since `destroy()` may have run while we waited.
1639
+ return this.modulePromise.then((mod) => {
1640
+ if (myGeneration !== this.generation) {
1641
+ throw new NgxMediaError('ABORTED', `${this.id}: destroyed while its WASM module was loading.`);
1642
+ }
1643
+ return mod;
1644
+ });
1645
+ }
1646
+ destroy() {
1647
+ this.generation++;
1648
+ this.worker?.terminate();
1649
+ this.worker = null;
1650
+ this.modulePromise = null;
1651
+ }
1652
+ }
1653
+ /**
1654
+ * Minimal playable-engine wrapper around a fully-decoded `AudioBuffer`,
1655
+ * useful for WASM decoders that decode short clips completely up front
1656
+ * rather than streaming. Reuses the browser's own `AudioContext` playback
1657
+ * graph instead of a native `<audio>` element, since there's no compressed
1658
+ * file for the browser to play directly anymore.
1659
+ *
1660
+ * Playback-position bookkeeping: `offset` is the buffer-time position when
1661
+ * the current `AudioBufferSourceNode` was (re)started, `startedAt` is the
1662
+ * `AudioContext.currentTime` at that moment, and `rate` is the playback
1663
+ * rate in effect since then — so at any instant, position =
1664
+ * `offset + (ctx.currentTime - startedAt) * rate`. Every operation that
1665
+ * stops or restarts the source (pause, seek, rate change while playing,
1666
+ * replay after ended) re-baselines these three fields together so that
1667
+ * formula stays correct instead of drifting.
1668
+ */
1669
+ class DecodedBufferEngine {
1670
+ buffer;
1671
+ id;
1672
+ kind = 'audio';
1673
+ mediaElement = null;
1674
+ _state = signal(initialMediaState(), /* @ts-ignore */
1675
+ ...(ngDevMode ? [{ debugName: "_state" }] : /* istanbul ignore next */ []));
1676
+ state = this._state.asReadonly();
1677
+ ctx;
1678
+ source = null;
1679
+ gain;
1680
+ startedAt = 0;
1681
+ offset = 0;
1682
+ rate = 1;
1683
+ volume = 1;
1684
+ muted = false;
1685
+ rafId = null;
1686
+ /** Bumped every time a NEW `AudioBufferSourceNode` is created, so a stale `onended` from a node that was `.stop()`-ed (rather than actually finishing) can recognize it's obsolete and not overwrite newer state. */
1687
+ playToken = 0;
1688
+ constructor(decoderId, ctx, buffer) {
1689
+ this.buffer = buffer;
1690
+ this.id = `wasm:${decoderId}`;
1691
+ this.ctx = ctx;
1692
+ this.gain = ctx.createGain();
1693
+ this.gain.connect(ctx.destination);
1694
+ this._state.update((s) => ({ ...s, duration: buffer.duration, playback: 'ready' }));
1695
+ }
1696
+ async play() {
1697
+ if (this.ctx.state === 'suspended')
1698
+ await this.ctx.resume();
1699
+ if (this._state().playback === 'ended' || this.offset >= this.buffer.duration) {
1700
+ // Replaying after the buffer finished naturally (or after seeking
1701
+ // exactly to its end) must restart from zero and update the exposed
1702
+ // position immediately, before the next animation-frame tick.
1703
+ this.offset = 0;
1704
+ this._state.update((s) => ({ ...s, currentTime: 0 }));
1705
+ }
1706
+ const myToken = ++this.playToken;
1707
+ this.source = this.ctx.createBufferSource();
1708
+ this.source.buffer = this.buffer;
1709
+ this.source.playbackRate.value = this.rate;
1710
+ this.source.connect(this.gain);
1711
+ this.source.onended = () => {
1712
+ if (myToken !== this.playToken)
1713
+ return; // stopped intentionally (pause/seek/rate-change), not a real "ended"
1714
+ this.stopTimeTracking();
1715
+ this.offset = this.buffer.duration;
1716
+ this.source = null;
1717
+ this._state.update((s) => ({ ...s, playback: 'ended', currentTime: this.buffer.duration }));
1718
+ };
1719
+ this.startedAt = this.ctx.currentTime;
1720
+ this.source.start(0, this.offset);
1721
+ this._state.update((s) => ({ ...s, playback: 'playing' }));
1722
+ this.startTimeTracking();
1723
+ }
1724
+ pause() {
1725
+ if (!this.source)
1726
+ return;
1727
+ this.offset = this.currentPosition();
1728
+ this.playToken++; // the upcoming onended (if it fires at all) is now stale
1729
+ this.source.stop();
1730
+ this.source = null;
1731
+ this.stopTimeTracking();
1732
+ this._state.update((s) => ({ ...s, playback: 'paused', currentTime: this.offset }));
1733
+ }
1734
+ seek(time) {
1735
+ const wasPlaying = this._state().playback === 'playing';
1736
+ this.offset = Math.min(Math.max(time, 0), this.buffer.duration);
1737
+ if (this.source) {
1738
+ this.playToken++; // stale-onended guard — this stop is intentional, not the buffer actually ending
1739
+ this.source.stop();
1740
+ this.source = null;
1741
+ }
1742
+ this._state.update((s) => ({ ...s, currentTime: this.offset, playback: wasPlaying ? s.playback : 'paused' }));
1743
+ if (wasPlaying)
1744
+ void this.play();
1745
+ }
1746
+ setVolume(volume) {
1747
+ this.volume = Math.min(Math.max(volume, 0), 1);
1748
+ if (!this.muted)
1749
+ this.gain.gain.value = this.volume;
1750
+ this._state.update((s) => ({ ...s, volume: this.volume }));
1751
+ }
1752
+ setMuted(muted) {
1753
+ this.muted = muted;
1754
+ this.gain.gain.value = muted ? 0 : this.volume;
1755
+ this._state.update((s) => ({ ...s, muted }));
1756
+ }
1757
+ setPlaybackRate(rate) {
1758
+ if (this.source && this._state().playback === 'playing') {
1759
+ // Re-baseline position bookkeeping BEFORE changing rate, or the
1760
+ // offset+elapsed*rate formula would retroactively (and incorrectly)
1761
+ // apply the new rate to time that already played at the old one.
1762
+ this.offset = this.currentPosition();
1763
+ this.startedAt = this.ctx.currentTime;
1764
+ this.source.playbackRate.value = rate;
1765
+ }
1766
+ this.rate = rate;
1767
+ this._state.update((s) => ({ ...s, playbackRate: rate }));
1768
+ }
1769
+ async load() {
1770
+ /* buffer is already decoded at construction time */
1771
+ }
1772
+ destroy() {
1773
+ this.playToken++;
1774
+ this.stopTimeTracking();
1775
+ this.source?.stop();
1776
+ this.source = null;
1777
+ this.gain.disconnect();
1778
+ this._state.update((s) => ({ ...s, playback: 'destroyed' }));
1779
+ }
1780
+ currentPosition() {
1781
+ if (!this.source)
1782
+ return this.offset;
1783
+ return Math.min(this.offset + (this.ctx.currentTime - this.startedAt) * this.rate, this.buffer.duration);
1784
+ }
1785
+ /** Drives `currentTime` for the UI while actually playing — never runs during pause/ended/destroyed, so there's no animation loop burning frames when nothing is moving. */
1786
+ startTimeTracking() {
1787
+ if (this.rafId !== null || typeof requestAnimationFrame === 'undefined')
1788
+ return;
1789
+ const tick = () => {
1790
+ if (this._state().playback !== 'playing') {
1791
+ this.rafId = null;
1792
+ return;
1793
+ }
1794
+ const t = this.currentPosition();
1795
+ if (t !== this._state().currentTime) {
1796
+ this._state.update((s) => ({ ...s, currentTime: t }));
1797
+ }
1798
+ this.rafId = requestAnimationFrame(tick);
1799
+ };
1800
+ this.rafId = requestAnimationFrame(tick);
1801
+ }
1802
+ stopTimeTracking() {
1803
+ if (this.rafId !== null && typeof cancelAnimationFrame !== 'undefined') {
1804
+ cancelAnimationFrame(this.rafId);
1805
+ }
1806
+ this.rafId = null;
1807
+ }
1808
+ }
1809
+
1810
+ /**
1811
+ * Root-level setup: `bootstrapApplication(App, { providers: [provideNgxMedia({...})] })`.
1812
+ * The supplied config is merged over the library's own defaults, not
1813
+ * replacing them outright — `provideNgxMedia({ preload: 'auto' })` must not
1814
+ * silently drop `maxConcurrentDecoderProbes` and leave it `undefined`.
1815
+ */
1816
+ function provideNgxMedia(config) {
1817
+ const merged = { ...NGX_MEDIA_CONFIG_DEFAULTS, ...config };
1818
+ return makeEnvironmentProviders([{ provide: NGX_MEDIA_CONFIG, useValue: merged }]);
1819
+ }
1820
+ /**
1821
+ * Registers a decoder plugin into the `NGX_MEDIA_DECODER` multi-provider
1822
+ * token. Accepts a class (instantiated via DI, so it can itself inject
1823
+ * services) or a ready-made instance for simple cases.
1824
+ *
1825
+ * bootstrapApplication(AppComponent, {
1826
+ * providers: [provideNgxMediaDecoder(My3gpDecoder)],
1827
+ * });
1828
+ */
1829
+ function provideNgxMediaDecoder(decoder) {
1830
+ const provider = typeof decoder === 'function'
1831
+ ? { provide: NGX_MEDIA_DECODER, useClass: decoder, multi: true }
1832
+ : { provide: NGX_MEDIA_DECODER, useValue: decoder, multi: true };
1833
+ return makeEnvironmentProviders([provider]);
1834
+ }
1835
+ /** Registers a custom network layer (auth headers, signed URLs, a non-fetch HTTP client, ...). */
1836
+ function provideNgxMediaRequestHandler(handler) {
1837
+ return makeEnvironmentProviders([{ provide: NGX_MEDIA_REQUEST_HANDLER, useValue: handler }]);
1838
+ }
1839
+
1840
+ // New primary component
1841
+
1842
+ /**
1843
+ * Generated bundle index. Do not edit.
1844
+ */
1845
+
1846
+ export { DecodedBufferEngine, NGX_MEDIA_CONFIG, NGX_MEDIA_CONFIG_DEFAULTS, NGX_MEDIA_DECODER, NGX_MEDIA_REQUEST_HANDLER, NativeMediaEngine, NgxMediaControl, NgxMediaError, NgxWasmDecoderBase, PlayList, canPlayNatively, canTransition, extractMediaFileName, formatTime, getAudioContextCtor, hasAudioContext, hasFullscreen, hasMediaSession, hasMediaSource, hasPictureInPicture, initialMediaState, isBrowser, mediaSourcesEqual, playListToMediaSource, provideNgxMedia, provideNgxMediaDecoder, provideNgxMediaRequestHandler };
1847
+ //# sourceMappingURL=ngx-kit-media-control.mjs.map