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.
- package/fesm2022/ngx-kit-box-shadow.mjs +2 -2
- package/fesm2022/ngx-kit-box-shadow.mjs.map +1 -1
- package/fesm2022/ngx-kit-date-picker.mjs +27 -22
- package/fesm2022/ngx-kit-date-picker.mjs.map +1 -1
- package/fesm2022/ngx-kit-dialog.mjs +6 -5
- package/fesm2022/ngx-kit-dialog.mjs.map +1 -1
- package/fesm2022/ngx-kit-media-control-mse-media-engine-CtP97cb9.mjs +326 -0
- package/fesm2022/ngx-kit-media-control-mse-media-engine-CtP97cb9.mjs.map +1 -0
- package/fesm2022/ngx-kit-media-control.mjs +1847 -0
- package/fesm2022/ngx-kit-media-control.mjs.map +1 -0
- package/media-control/README.md +349 -0
- package/package.json +5 -1
- package/types/ngx-kit-date-picker.d.ts +1 -1
- package/types/ngx-kit-dialog.d.ts +1 -2
- package/types/ngx-kit-media-control.d.ts +671 -0
|
@@ -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
|