demuxe 0.3.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +352 -0
  2. package/README.md +75 -0
  3. package/bin/demuxe.mjs +30 -0
  4. package/docs/API-MIGRATION.md +54 -0
  5. package/docs/BETA.md +98 -0
  6. package/docs/BRANDING-MIGRATION.md +77 -0
  7. package/docs/COMPATIBILITY-EXPANSION.md +181 -0
  8. package/docs/LICENSING.md +83 -0
  9. package/docs/OPTIMIZATION-COMPLETION.md +511 -0
  10. package/docs/OPTIMIZATION-FLAC.md +178 -0
  11. package/docs/OPTIMIZATION-INTEGRATION.md +221 -0
  12. package/docs/OPTIMIZATION-REVIEW-FIXES.md +78 -0
  13. package/docs/PLAYER-COMPONENT.md +247 -0
  14. package/docs/PUBLIC-API-VALIDATION.md +163 -0
  15. package/docs/PUBLIC-API.md +245 -0
  16. package/docs/RELEASE.md +181 -0
  17. package/docs/RUNTIME-ASSETS.md +127 -0
  18. package/engine-build.json +8781 -0
  19. package/examples/custom-controls.html +14 -0
  20. package/examples/player-element.html +5 -0
  21. package/fixtures/DejaVuSans.ttf +0 -0
  22. package/fixtures/FONT-LICENSE.txt +187 -0
  23. package/index.d.ts +1 -0
  24. package/index.js +1 -0
  25. package/package.json +46 -0
  26. package/player.d.ts +1 -0
  27. package/player.js +1 -0
  28. package/release-manifest.json +541 -0
  29. package/sources.lock.json +96 -0
  30. package/third_party/notices/dav1d/COPYING +23 -0
  31. package/third_party/notices/emscripten/LICENSE +102 -0
  32. package/third_party/notices/emscripten/system/lib/compiler-rt/LICENSE.TXT +311 -0
  33. package/third_party/notices/emscripten/system/lib/libc/musl/COPYRIGHT +193 -0
  34. package/third_party/notices/emscripten/system/lib/libcxx/LICENSE.TXT +311 -0
  35. package/third_party/notices/emscripten/system/lib/libcxxabi/LICENSE.TXT +311 -0
  36. package/third_party/notices/emsdk/LICENSE +27 -0
  37. package/third_party/notices/ffmpeg/COPYING.GPLv2 +339 -0
  38. package/third_party/notices/ffmpeg/COPYING.GPLv3 +674 -0
  39. package/third_party/notices/ffmpeg/COPYING.LGPLv2.1 +502 -0
  40. package/third_party/notices/ffmpeg/LICENSE.md +129 -0
  41. package/third_party/notices/ffmpeg-adaptation/COPYING.LGPLv2.1 +502 -0
  42. package/third_party/notices/ffmpeg-adaptation/LICENSE.md +127 -0
  43. package/third_party/notices/freetype/LICENSE.TXT +46 -0
  44. package/third_party/notices/freetype/docs/FTL.TXT +169 -0
  45. package/third_party/notices/freetype/docs/GPLv2.TXT +340 -0
  46. package/third_party/notices/fribidi/COPYING +504 -0
  47. package/third_party/notices/harfbuzz/COPYING +42 -0
  48. package/third_party/notices/libass/COPYING +15 -0
  49. package/third_party/notices/libplacebo/LICENSE +458 -0
  50. package/third_party/notices/libxml2/Copyright +23 -0
  51. package/third_party/notices/libxml2/dict.c.notice +17 -0
  52. package/third_party/notices/libxml2/list.c.notice +16 -0
  53. package/third_party/notices/mpv/Copyright +78 -0
  54. package/third_party/notices/mpv/LICENSE.GPL +338 -0
  55. package/third_party/notices/mpv/LICENSE.LGPL +501 -0
  56. package/third_party/notices/vulkan-headers/LICENSE.txt +202 -0
  57. package/third_party/notices/zimg/COPYING +14 -0
  58. package/third_party/notices/zlib/LICENSE +22 -0
  59. package/third_party/notices.json +166 -0
  60. package/toolchain.lock.json +204 -0
  61. package/web/audio-worklet.js +45 -0
  62. package/web/cheap-mp4-probe.js +82 -0
  63. package/web/engine-adaptation/manifest.json +75 -0
  64. package/web/engine-adaptation/remux.mjs +2 -0
  65. package/web/engine-adaptation/remux.wasm +0 -0
  66. package/web/engine-ass/manifest.json +55 -0
  67. package/web/engine-ass/subtitles.mjs +2 -0
  68. package/web/engine-ass/subtitles.wasm +0 -0
  69. package/web/engine-hybrid/player.mjs +2 -0
  70. package/web/engine-hybrid/player.wasm +0 -0
  71. package/web/engine-remux/remux.mjs +2 -0
  72. package/web/engine-remux/remux.wasm +0 -0
  73. package/web/engine-software-full/player.mjs +2 -0
  74. package/web/engine-software-full/player.wasm +0 -0
  75. package/web/file-reader.js +34 -0
  76. package/web/filter-retained-engine-worker.js +292 -0
  77. package/web/generated/index.d.ts +6 -0
  78. package/web/generated/index.js +4 -0
  79. package/web/generated/internal/assets.d.ts +2 -0
  80. package/web/generated/internal/assets.js +7 -0
  81. package/web/generated/internal/backend.d.ts +27 -0
  82. package/web/generated/internal/backend.js +1 -0
  83. package/web/generated/internal/errors.d.ts +13 -0
  84. package/web/generated/internal/errors.js +54 -0
  85. package/web/generated/internal/native-ass.d.ts +38 -0
  86. package/web/generated/internal/native-ass.js +206 -0
  87. package/web/generated/internal/native-player.d.ts +95 -0
  88. package/web/generated/internal/native-player.js +502 -0
  89. package/web/generated/internal/playback-plans.d.ts +526 -0
  90. package/web/generated/internal/playback-plans.js +110 -0
  91. package/web/generated/internal/selection.d.ts +45 -0
  92. package/web/generated/internal/selection.js +45 -0
  93. package/web/generated/internal/state.d.ts +8 -0
  94. package/web/generated/internal/state.js +56 -0
  95. package/web/generated/internal/wasm-player.d.ts +127 -0
  96. package/web/generated/internal/wasm-player.js +372 -0
  97. package/web/generated/player/index.d.ts +198 -0
  98. package/web/generated/player/index.js +873 -0
  99. package/web/generated/player/interaction.d.ts +6 -0
  100. package/web/generated/player/interaction.js +14 -0
  101. package/web/generated/player/styles.d.ts +1 -0
  102. package/web/generated/player/styles.js +86 -0
  103. package/web/generated/types.d.ts +227 -0
  104. package/web/generated/types.js +2 -0
  105. package/web/generated/unified-player.d.ts +129 -0
  106. package/web/generated/unified-player.js +1074 -0
  107. package/web/io-worker.js +65 -0
  108. package/web/native-ass-worker.js +26 -0
  109. package/web/native-remux-player.js +301 -0
  110. package/web/native-remux-source-worker.js +25 -0
  111. package/web/native-remux-worker.js +46 -0
  112. package/web/range-reader.js +130 -0
  113. package/web/remux-packaging.js +11 -0
  114. package/web/resource-loader.js +173 -0
  115. package/web/retained-decoder-worker.js +200 -0
  116. package/web/retained-video.js +15 -0
  117. package/web/segmented-subtitles.js +37 -0
  118. package/web/software-full-engine-worker.js +234 -0
  119. package/web/source-probe.js +28 -0
  120. package/web/split-mp4.js +159 -0
  121. package/web/streaming-manifest.js +136 -0
  122. package/web/subtitle-overlay.js +26 -0
  123. package/web/video-codec-config.js +59 -0
  124. package/web/vod-manifest.js +54 -0
@@ -0,0 +1,873 @@
1
+ import { Player } from '../unified-player.js';
2
+ import { PLAYER_EVENTS } from '../types.js';
3
+ import { PlayerError, playerError } from '../internal/errors.js';
4
+ import { formatTime, outputDimensions, shortcut } from './interaction.js';
5
+ import { styles } from './styles.js';
6
+ // Mirror only the arrow so both directions retain upright, centered numerals.
7
+ const seekArrow = '<path d="M10 5h2a8 8 0 1 1-8 8M13 2l-3 3 3 3"/>';
8
+ const seekSeconds = '<text x="12" y="16" text-anchor="middle" fill="currentColor" stroke="none" font-size="8.5" font-weight="450" font-family="system-ui,sans-serif">10</text>';
9
+ const icons = {
10
+ previous: '<path d="M5 5v14m14-14L8 12l11 7Z" fill="currentColor"/>',
11
+ next: '<path d="M19 5v14M5 5l11 7-11 7Z" fill="currentColor"/>',
12
+ eyeOff: '<path d="m3 3 18 18M10.6 5.1A11 11 0 0 1 12 5c6.5 0 10 7 10 7a18 18 0 0 1-3.2 4.1M6.1 6.1A19 19 0 0 0 2 12s3.5 7 10 7a12 12 0 0 0 5.1-1.2M9.9 9.9a3 3 0 0 0 4.2 4.2"/>',
13
+ eye: '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
14
+ back: `${seekArrow}${seekSeconds}`,
15
+ forward: `<g transform="translate(24 0) scale(-1 1)">${seekArrow}</g>${seekSeconds}`,
16
+ play: '<path d="m9 5 11 7-11 7Z" fill="currentColor" stroke="none"/>',
17
+ pause: '<path d="M8 5v14M16 5v14" stroke-width="4"/>',
18
+ volume: '<path d="m11 5-6 4H2v6h3l6 4Z"/><path d="M15 8a6 6 0 0 1 0 8m3-11a10 10 0 0 1 0 14"/>',
19
+ muted: '<path d="m11 5-6 4H2v6h3l6 4Z"/><path d="m16 9 6 6m0-6-6 6"/>',
20
+ settings: '<path d="M9 3h6l.6 2.3 2 1.2 2.3-.6 3 5.2-1.7 1.7v2.4l1.7 1.7-3 5.2-2.3-.6-2 1.2L15 24H9l-.6-2.3-2-1.2-2.3.6-3-5.2 1.7-1.7v-2.4L1.1 11l3-5.2 2.3.6 2-1.2Z" transform="translate(1 0) scale(.9)"/><circle cx="12" cy="12" r="3"/>',
21
+ expand: '<path d="M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5"/>',
22
+ collapse: '<path d="M3 8h5V3m8 0v5h5M8 21v-5H3m13 5v-5h5"/>',
23
+ folder: '<path d="M3 7V5a1 1 0 0 1 1-1h5l2 3h9a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7Z"/>',
24
+ folderOpen: '<path d="M3 8V5a1 1 0 0 1 1-1h5l2 3h9a1 1 0 0 1 1 1v2M3 8h17a1 1 0 0 1 1 1l-2 10H3L1 9a1 1 0 0 1 1-1Z"/>',
25
+ close: '<path d="m6 6 12 12M18 6 6 18"/>'
26
+ };
27
+ const Base = (typeof HTMLElement === 'undefined' ? class {
28
+ } : HTMLElement);
29
+ export const defaultLabels = Object.freeze({ diagnostics: 'Session diagnostics', moreOptions: 'More options', back: 'Seek backward 10 seconds', forward: 'Seek forward 10 seconds', play: 'Play', pause: 'Pause', mute: 'Mute', unmute: 'Unmute', seek: 'Playback position', volume: 'Volume', settings: 'Playback settings', closeSettings: 'Close settings', speed: 'Playback speed', audio: 'Audio', subtitles: 'Subtitles', automatic: 'Automatic', off: 'Off', fullscreen: 'Fullscreen', exitFullscreen: 'Exit fullscreen', open: 'Open media', addSubtitle: 'Add subtitles', empty: 'Something good to watch?', drop: 'Open a video or audio file from your device.', loading: 'Opening media…', switching: 'Updating playback…', seeking: 'Seeking…', buffering: 'Buffering…', live: 'LIVE', unknown: 'Unknown duration', retry: 'Retry', resume: 'Press Play to continue', shortcuts: 'K / Space: play · ← → / J L: seek · ↑ ↓: volume · M: mute · C: subtitles · [ ]: speed · 0–9 / Home / End: position · F: fullscreen', noFullscreen: 'Fullscreen is unavailable here. Open this page in a browser tab.', noWindow: 'Live playback · seek window unavailable', openURL: 'Open URL', closeMedia: 'Close media', url: 'Media URL', format: 'Source format', streamLive: 'Live stream', addFiles: 'Add files', queue: 'Queue', clearQueue: 'Clear queue', previous: 'Previous file', next: 'Next file', remove: 'Remove', unnamed: 'Unnamed media', mediaFile: 'Media file', noMedia: 'No media loaded', loadedMedia: 'Media loaded', subtitleFile: 'Subtitle file' });
30
+ // Never display opaque URL payloads, origins, credentials, queries or fragments.
31
+ function sourceTitle(source) {
32
+ if (typeof File !== 'undefined' && source instanceof File)
33
+ return source.name;
34
+ const value = typeof source === 'string' || source instanceof URL ? source :
35
+ 'url' in source ? source.url : undefined;
36
+ if (value === undefined)
37
+ return '';
38
+ try {
39
+ const url = new URL(String(value), document.baseURI);
40
+ if (!['http:', 'https:', 'file:'].includes(url.protocol))
41
+ return '';
42
+ const filename = url.pathname.split('/').at(-1) ?? '';
43
+ try {
44
+ return decodeURIComponent(filename);
45
+ }
46
+ catch {
47
+ return filename;
48
+ }
49
+ }
50
+ catch {
51
+ return '';
52
+ }
53
+ }
54
+ export class DemuxePlayerElement extends Base {
55
+ static observedAttributes = ['src', 'controls', 'poster', 'autoplay', 'muted', 'asset-base', 'title', 'title-mode'];
56
+ core;
57
+ queueItems = [];
58
+ queueIndex = -1;
59
+ queueOperation;
60
+ queuePlayIntent;
61
+ queueSourceId = null;
62
+ queueEndedId = null;
63
+ queueSignature = '';
64
+ queueRevision = 0;
65
+ queueRenderSignature = '';
66
+ queueItem(source, options = {}) {
67
+ let name = '';
68
+ try {
69
+ name = sourceTitle(source);
70
+ }
71
+ catch { }
72
+ return { source, options: { ...options, signal: undefined }, name };
73
+ }
74
+ resetQueue() {
75
+ this.queueRevision++;
76
+ this.queueItems = [];
77
+ this.queueIndex = -1;
78
+ this.queueOperation = undefined;
79
+ this.queueSourceId = null;
80
+ this.queueEndedId = null;
81
+ this.renderQueue();
82
+ }
83
+ async activateQueue(index, playAfter = this.core?.state.playbackIntent === 'play' || this.core?.state.status === 'ended', options, closePreviousOnFailure = false) {
84
+ if (this.terminal)
85
+ throw new PlayerError('ABORTED', 'Player element is destroyed');
86
+ const item = this.queueItems[index];
87
+ if (!item)
88
+ return;
89
+ const operation = this.queueOperation = Symbol();
90
+ this.queuePlayIntent = undefined;
91
+ this.queueIndex = index;
92
+ this.queueSourceId = null;
93
+ this.queueEndedId = null;
94
+ this.renderQueue();
95
+ try {
96
+ await this.openSource(item.source, options ?? item.options);
97
+ if (this.queueOperation !== operation)
98
+ throw new PlayerError('ABORTED', 'Queue selection superseded');
99
+ this.queueSourceId = this.core?.state.sourceId ?? null;
100
+ if (this.queuePlayIntent ?? (typeof playAfter === 'function' ? playAfter() : playAfter))
101
+ await this.core?.play();
102
+ }
103
+ catch (error) {
104
+ // A removed item must not survive as the core's rollback source.
105
+ if (closePreviousOnFailure && this.queueOperation === operation && this.queueSourceId === null)
106
+ await this.core?.close();
107
+ throw error;
108
+ }
109
+ finally {
110
+ if (this.queueOperation === operation) {
111
+ this.queueOperation = undefined;
112
+ this.renderQueue();
113
+ queueMicrotask(() => this.advanceQueue());
114
+ }
115
+ }
116
+ }
117
+ addFiles(files) {
118
+ if (this.terminal || !files.length)
119
+ return;
120
+ const start = this.queueItems.length;
121
+ this.queueRevision++;
122
+ for (const file of files)
123
+ this.queueItems.push(this.queueItem(file));
124
+ this.settings(false, false);
125
+ this.$('stage').focus({ preventScroll: true });
126
+ this.renderQueue();
127
+ if (start === 0)
128
+ this.run(this.activateQueue(0, () => this.autoplay));
129
+ }
130
+ selectQueue(index) {
131
+ if (this.terminal || this.queueOperation || this.core?.state.pendingOperation)
132
+ return;
133
+ this.settings(false, false);
134
+ this.$('stage').focus({ preventScroll: true });
135
+ this.run(this.activateQueue(index));
136
+ }
137
+ removeQueueItem(index) {
138
+ if (!this.showSourceControls || this.queueOperation || this.core?.state.pendingOperation || index < 0 || index >= this.queueItems.length)
139
+ return;
140
+ const wasCurrent = index === this.queueIndex;
141
+ this.queueRevision++;
142
+ this.queueItems.splice(index, 1);
143
+ if (!this.queueItems.length) {
144
+ this.settings(false, false);
145
+ this.$('stage').focus({ preventScroll: true });
146
+ this.run(this.close());
147
+ return;
148
+ }
149
+ if (index < this.queueIndex)
150
+ this.queueIndex--;
151
+ if (wasCurrent) {
152
+ this.settings(false, false);
153
+ this.$('stage').focus({ preventScroll: true });
154
+ this.run(this.activateQueue(Math.min(index, this.queueItems.length - 1), undefined, undefined, true));
155
+ return;
156
+ }
157
+ this.renderQueue();
158
+ this.$('queue-list').querySelector('button')?.focus();
159
+ }
160
+ advanceQueue() {
161
+ const state = this.core?.state;
162
+ if (!state || this.terminal || this.queueOperation || state.pendingOperation || state.status !== 'ended' || state.sourceId !== this.queueSourceId || this.queueEndedId === state.sourceId || this.queueIndex >= this.queueItems.length - 1)
163
+ return;
164
+ this.queueEndedId = state.sourceId;
165
+ this.run(this.activateQueue(this.queueIndex + 1, true));
166
+ }
167
+ renderQueue() {
168
+ if (!this.shadowRoot?.getElementById('queue-list'))
169
+ return;
170
+ const busy = !!this.queueOperation || !!this.core?.state.pendingOperation, labels = this.labels;
171
+ const renderSignature = JSON.stringify([this.queueRevision, this.queueIndex, busy, this.showSourceControls, labels.queue, labels.clearQueue, labels.previous, labels.next, labels.remove, labels.unnamed, labels.open, labels.addFiles]);
172
+ if (renderSignature === this.queueRenderSignature)
173
+ return;
174
+ this.queueRenderSignature = renderSignature;
175
+ this.$('queue-section').hidden = !this.queueItems.length;
176
+ this.$('queue-navigation').hidden = this.queueItems.length < 2;
177
+ this.$('queue-count').textContent = `${this.queueIndex + 1} / ${this.queueItems.length}`;
178
+ this.$('choose-file').textContent = this.queueItems.length ? this.labels.addFiles : this.labels.open;
179
+ this.$('queue-heading').textContent = this.labels.queue;
180
+ this.$('clear-queue').textContent = this.labels.clearQueue;
181
+ this.$('clear-queue').disabled = !this.showSourceControls || busy;
182
+ this.iconButton('previous-file', 'previous', this.labels.previous);
183
+ this.iconButton('next-file', 'next', this.labels.next);
184
+ this.$('previous-file').disabled = busy || this.queueIndex <= 0;
185
+ this.$('next-file').disabled = busy || this.queueIndex >= this.queueItems.length - 1;
186
+ const signature = JSON.stringify([this.queueRevision, labels.remove, labels.unnamed]);
187
+ if (signature !== this.queueSignature) {
188
+ this.queueSignature = signature;
189
+ this.$('queue-list').replaceChildren();
190
+ for (const item of this.queueItems) {
191
+ const row = document.createElement('li'), choose = document.createElement('button'), remove = document.createElement('button');
192
+ choose.type = remove.type = 'button';
193
+ choose.className = 'queue-item';
194
+ remove.className = 'queue-remove';
195
+ choose.textContent = item.name || this.labels.unnamed;
196
+ choose.title = choose.textContent;
197
+ choose.onclick = () => { if (this.showSourceControls)
198
+ this.selectQueue(this.queueItems.indexOf(item)); };
199
+ remove.textContent = '×';
200
+ remove.setAttribute('aria-label', `${this.labels.remove} ${item.name || this.labels.unnamed}`);
201
+ remove.onclick = () => this.removeQueueItem(this.queueItems.indexOf(item));
202
+ row.append(choose, remove);
203
+ this.$('queue-list').append(row);
204
+ }
205
+ }
206
+ Array.from(this.$('queue-list').children).forEach((row, index) => {
207
+ const choose = row.querySelector('.queue-item');
208
+ if (index === this.queueIndex)
209
+ choose.setAttribute('aria-current', 'true');
210
+ else
211
+ choose.removeAttribute('aria-current');
212
+ row.querySelectorAll('button').forEach(button => button.disabled = busy || !this.showSourceControls);
213
+ });
214
+ }
215
+ sourceName = '';
216
+ sourceNameId = null;
217
+ sourceControls = true;
218
+ diagnosticsControl = true;
219
+ fileDrop = true;
220
+ seekSeconds = 10;
221
+ autoHideDelay = 2800;
222
+ get titleMode() {
223
+ const mode = this.getAttribute('title-mode');
224
+ return mode === 'custom' || mode === 'source' || mode === 'none' ? mode : 'auto';
225
+ }
226
+ set titleMode(value) {
227
+ if (!['auto', 'custom', 'source', 'none'].includes(value))
228
+ throw new PlayerError('INVALID_ARGUMENT', 'Invalid titleMode');
229
+ this.setAttribute('title-mode', value);
230
+ }
231
+ get showSourceControls() { return this.sourceControls; }
232
+ set showSourceControls(value) { this.sourceControls = !!value; this.updateUtilities(); }
233
+ get showDiagnostics() { return this.diagnosticsControl; }
234
+ set showDiagnostics(value) { this.diagnosticsControl = !!value; this.updateUtilities(); }
235
+ get allowFileDrop() { return this.fileDrop; }
236
+ set allowFileDrop(value) { this.fileDrop = !!value; }
237
+ get seekStep() { return this.seekSeconds; }
238
+ set seekStep(value) {
239
+ if (!Number.isFinite(value) || value <= 0)
240
+ throw new PlayerError('INVALID_ARGUMENT', 'seekStep must be a positive finite number');
241
+ this.seekSeconds = value;
242
+ this.labelControls();
243
+ if (this.core)
244
+ this.update(this.core.state);
245
+ }
246
+ get controlsAutoHideDelay() { return this.autoHideDelay; }
247
+ set controlsAutoHideDelay(value) {
248
+ if (!Number.isFinite(value) || value < 0 || value > 2147483647)
249
+ throw new PlayerError('INVALID_ARGUMENT', 'controlsAutoHideDelay must be between 0 and 2147483647 milliseconds');
250
+ this.autoHideDelay = value;
251
+ this.revealControls();
252
+ }
253
+ updateTitle() {
254
+ const text = this.titleMode === 'none' ? '' : this.titleMode === 'custom' ? this.title :
255
+ this.titleMode === 'source' ? this.sourceName : this.title || this.sourceName;
256
+ this.$('title').textContent = text;
257
+ this.$('title').hidden = !text;
258
+ this.updateSourceLabel();
259
+ }
260
+ updateSourceLabel() {
261
+ const text = this.sourceName || (!this.terminal && this.isConnected && this.core?.state.sourceId ? this.labels.loadedMedia : this.labels.noMedia);
262
+ if (this.$('current-source').textContent !== text)
263
+ this.$('current-source').textContent = text;
264
+ }
265
+ updateUtilities() {
266
+ if (this.terminal)
267
+ return;
268
+ const focused = this.shadowRoot?.activeElement;
269
+ const sourceFocused = !!focused && (this.$('source-options').contains(focused) || this.$('open-menu') === focused || this.$('open') === focused || (!this.$('settings').hidden && this.menuTrigger === 'open-menu' && this.$('settings').contains(focused)));
270
+ const diagnosticsFocused = focused === this.$('diagnostics-toggle') || focused === this.$('diagnostics-overlay');
271
+ if (!this.showSourceControls && this.menuTrigger === 'open-menu')
272
+ this.settings(false, false);
273
+ this.$('open-menu').hidden = !this.showSourceControls;
274
+ this.$('empty').hidden = !this.showSourceControls || !!this.core?.state.sourceId;
275
+ for (const id of ['open-menu', 'open', 'choose-file', 'file', 'subtitleFile', 'url', 'format', 'live', 'url-submit'])
276
+ this.$(id).disabled = !this.showSourceControls;
277
+ this.$('source-options').inert = !this.showSourceControls;
278
+ if (!this.showSourceControls)
279
+ this.$('source-options').hidden = true;
280
+ this.$('diagnostics-toggle').hidden = !this.showDiagnostics;
281
+ this.$('diagnostics-toggle').disabled = !this.showDiagnostics;
282
+ if (!this.showDiagnostics)
283
+ this.setDiagnostics(false);
284
+ this.renderQueue();
285
+ if ((!this.showSourceControls && sourceFocused) || (!this.showDiagnostics && diagnosticsFocused))
286
+ this.$('stage').focus({ preventScroll: true });
287
+ }
288
+ terminal = false;
289
+ cleanup = Promise.resolve();
290
+ connecting;
291
+ connection = 0;
292
+ unsubscribe;
293
+ sourceAbort;
294
+ sourceVersion = 0;
295
+ lastSource;
296
+ lastOptions;
297
+ resolveReady;
298
+ rejectReady;
299
+ readiness;
300
+ overrides = {};
301
+ menuTrigger = 'settings-toggle';
302
+ seekPreviewTimer;
303
+ hideTimer;
304
+ revealControls = () => { this.$('shell').classList.remove('idle', 'seek-preview'); clearTimeout(this.seekPreviewTimer); clearTimeout(this.hideTimer); if (this.core?.state.status === 'playing' && this.controlsAutoHideDelay > 0)
305
+ this.hideTimer = setTimeout(() => { if (this.core?.state.status === 'playing' && !this.core.state.pendingOperation && this.$('settings').hidden && !this.dragging && !this.shadowRoot?.activeElement?.matches(':focus-visible') && this.isConnected)
306
+ this.hideControls(); }, this.controlsAutoHideDelay); };
307
+ async playFromControls() { const core = this.core; await this.play(); if (core === this.core && core?.state.playbackIntent === 'play' && this.$('settings').hidden)
308
+ this.hideControls(true); }
309
+ hideControls(focusStage = false) { if (focusStage || this.shadowRoot?.activeElement)
310
+ this.$('stage').focus({ preventScroll: true }); clearTimeout(this.hideTimer); clearTimeout(this.seekPreviewTimer); this.$('shell').classList.remove('seek-preview'); this.$('shell').classList.add('idle'); }
311
+ dismissMenu = (event) => { const path = event.composedPath(); if (!this.$('settings').hidden && !['settings', 'settings-toggle', 'open-menu'].some(id => path.includes(this.$(id))))
312
+ this.settings(false, false); };
313
+ stageWasIdle = false;
314
+ isScreenPress(event) { return !event.composedPath().some(node => node instanceof Element && node.matches('button,input,select,textarea,a,summary,[contenteditable],[role="button"],#settings,#error,#diagnostics-overlay')); }
315
+ wasSeeking = false;
316
+ diagnosticsUpdated = 0;
317
+ dragging = false;
318
+ dimensions = '';
319
+ trackSignature = '';
320
+ reflected = false;
321
+ attributeScheduled = false;
322
+ configuredAsset = null;
323
+ resizeObserver;
324
+ lastAnnouncement = '';
325
+ lastFailure;
326
+ fullscreenChanged = () => { const active = document.fullscreenElement === this; this.$('fullscreen').setAttribute('aria-pressed', String(active)); this.iconButton('fullscreen', active ? 'collapse' : 'expand', active ? this.labels.exitFullscreen : this.labels.fullscreen); };
327
+ constructor() { super(); this.newReady(); this.attachShadow({ mode: 'open' }); this.renderShell(); }
328
+ newReady() { this.readiness = new Promise((resolve, reject) => { this.resolveReady = resolve; this.rejectReady = reject; }); void this.readiness.catch(() => { }); }
329
+ get ready() { return this.readiness; }
330
+ get player() { return this.core; }
331
+ get src() { return this.getAttribute('src') ?? ''; }
332
+ set src(value) { if (value)
333
+ this.setAttribute('src', String(value));
334
+ else
335
+ this.removeAttribute('src'); }
336
+ get controls() { return this.hasAttribute('controls'); }
337
+ set controls(value) { this.toggleAttribute('controls', !!value); }
338
+ get autoplay() { return this.hasAttribute('autoplay'); }
339
+ set autoplay(value) { this.toggleAttribute('autoplay', !!value); }
340
+ get muted() { return this.hasAttribute('muted'); }
341
+ set muted(value) { this.toggleAttribute('muted', !!value); }
342
+ get poster() { return this.getAttribute('poster') ?? ''; }
343
+ set poster(value) { if (value)
344
+ this.setAttribute('poster', value);
345
+ else
346
+ this.removeAttribute('poster'); }
347
+ get assetBase() { return this.getAttribute('asset-base') ?? undefined; }
348
+ set assetBase(value) { if (this.core)
349
+ throw new PlayerError('INVALID_ARGUMENT', 'assetBase is fixed after initialization'); if (value)
350
+ this.setAttribute('asset-base', value);
351
+ else
352
+ this.removeAttribute('asset-base'); }
353
+ get labels() { return { ...defaultLabels, back: `Seek backward ${this.seekStep} seconds`, forward: `Seek forward ${this.seekStep} seconds`, ...this.overrides }; }
354
+ set labels(value) { const next = {}; for (const [key, text] of Object.entries(value)) {
355
+ if (!(key in defaultLabels) || text === undefined)
356
+ continue;
357
+ if (typeof text !== 'string' || text.length > 1024)
358
+ throw new PlayerError('INVALID_ARGUMENT', 'Labels must be strings up to 1024 characters');
359
+ next[key] = text;
360
+ } this.overrides = next; this.labelControls(); if (this.core)
361
+ this.update(this.core.state); }
362
+ $(id) { return this.shadowRoot.getElementById(id); }
363
+ input(id) { return this.$(id); }
364
+ connectedCallback() {
365
+ const token = ++this.connection;
366
+ if (this.terminal)
367
+ return;
368
+ for (const name of ['assetBase', 'labels', 'controls', 'poster', 'autoplay', 'muted', 'title', 'titleMode', 'showSourceControls', 'showDiagnostics', 'allowFileDrop', 'seekStep', 'controlsAutoHideDelay', 'src'])
369
+ if (Object.prototype.hasOwnProperty.call(this, name)) {
370
+ const value = this[name];
371
+ delete this[name];
372
+ this[name] = value;
373
+ }
374
+ if (this.core)
375
+ return;
376
+ this.connecting = (async () => {
377
+ await this.cleanup;
378
+ if (!this.isConnected || token !== this.connection || this.terminal)
379
+ return;
380
+ try {
381
+ this.configuredAsset = this.getAttribute('asset-base');
382
+ const core = this.core = new Player(this.$('surface'), { assetBase: this.assetBase });
383
+ this.dimensions = '';
384
+ this.trackSignature = '';
385
+ for (const type of [...PLAYER_EVENTS, 'modechange', 'selectionchange', 'mpv', 'log', 'source', 'output'])
386
+ core.addEventListener(type, event => {
387
+ if (this.core !== core || this.terminal)
388
+ return;
389
+ const detail = event.detail;
390
+ if (type === 'error')
391
+ this.showError(detail);
392
+ if (type === 'ended')
393
+ queueMicrotask(() => { if (this.core === core)
394
+ this.advanceQueue(); });
395
+ this.dispatchEvent(new CustomEvent(type, { detail }));
396
+ });
397
+ const initiallyMuted = this.muted;
398
+ this.unsubscribe = core.subscribe(state => this.update(state));
399
+ if (initiallyMuted)
400
+ await core.setMuted(true);
401
+ this.resizeObserver = new ResizeObserver(() => { if (this.core)
402
+ this.geometry(this.core.state); });
403
+ this.resizeObserver.observe(this.$('stage'));
404
+ document.addEventListener('fullscreenchange', this.fullscreenChanged);
405
+ document.addEventListener('pointerdown', this.dismissMenu, true);
406
+ this.resolveReady(core);
407
+ if (this.src)
408
+ this.scheduleSource();
409
+ }
410
+ catch (error) {
411
+ this.rejectReady(playerError(error));
412
+ this.componentError(error);
413
+ }
414
+ })();
415
+ }
416
+ disconnectedCallback() {
417
+ const token = ++this.connection;
418
+ queueMicrotask(() => {
419
+ if (this.isConnected || token !== this.connection || this.terminal)
420
+ return;
421
+ clearTimeout(this.hideTimer);
422
+ clearTimeout(this.seekPreviewTimer);
423
+ this.sourceVersion++;
424
+ this.sourceAbort?.abort();
425
+ this.resetQueue();
426
+ this.lastSource = undefined;
427
+ this.lastOptions = undefined;
428
+ this.sourceName = '';
429
+ this.sourceNameId = null;
430
+ this.updateTitle();
431
+ this.unsubscribe?.();
432
+ this.resizeObserver?.disconnect();
433
+ document.removeEventListener('fullscreenchange', this.fullscreenChanged);
434
+ document.removeEventListener('pointerdown', this.dismissMenu, true);
435
+ const old = this.core;
436
+ this.core = undefined;
437
+ this.rejectReady(new PlayerError('ABORTED', 'Player element disconnected'));
438
+ this.newReady();
439
+ this.cleanup = Promise.all([this.connecting, old?.destroy()]).then(() => { });
440
+ });
441
+ }
442
+ attributeChangedCallback(name, old, value) {
443
+ if (old === value || this.reflected || this.terminal)
444
+ return;
445
+ if (name === 'title' || name === 'title-mode')
446
+ this.updateTitle();
447
+ if (name === 'asset-base' && this.core) {
448
+ this.reflected = true;
449
+ if (this.configuredAsset === null)
450
+ this.removeAttribute(name);
451
+ else
452
+ this.setAttribute(name, this.configuredAsset);
453
+ this.reflected = false;
454
+ this.componentError(new PlayerError('INVALID_ARGUMENT', 'asset-base is fixed after initialization'));
455
+ return;
456
+ }
457
+ if (name === 'controls' && !this.controls) {
458
+ const focused = this.shadowRoot?.activeElement;
459
+ const moveFocus = !!focused && ['topbar', 'settings', 'diagnostics-overlay'].some(id => this.$(id).contains(focused));
460
+ this.settings(false, false);
461
+ this.setDiagnostics(false);
462
+ if (moveFocus)
463
+ this.$('stage').focus({ preventScroll: true });
464
+ }
465
+ if (name === 'src' && this.core)
466
+ this.scheduleSource();
467
+ if (name === 'muted' && this.core)
468
+ this.run(this.core.setMuted(value !== null));
469
+ if (name === 'poster') {
470
+ const img = this.$('poster');
471
+ if (value)
472
+ img.src = value;
473
+ else
474
+ img.removeAttribute('src');
475
+ }
476
+ if (this.core)
477
+ this.update(this.core.state);
478
+ else {
479
+ this.$('controls').hidden = !this.controls;
480
+ this.$('topbar').hidden = !this.controls;
481
+ }
482
+ }
483
+ openFromControls(source) { this.settings(false, false); this.$('stage').focus({ preventScroll: true }); this.run(this.open(source)); }
484
+ scheduleSource() { if (this.attributeScheduled)
485
+ return; this.attributeScheduled = true; queueMicrotask(() => { this.attributeScheduled = false; if (!this.core || this.terminal)
486
+ return; this.run(this.src ? this.open(this.src) : this.close()); }); }
487
+ waitReady(signal) {
488
+ if (signal.aborted)
489
+ return Promise.reject(new PlayerError('ABORTED', 'Open aborted'));
490
+ return new Promise((resolve, reject) => { const abort = () => { signal.removeEventListener('abort', abort); reject(new PlayerError('ABORTED', 'Open aborted')); }; signal.addEventListener('abort', abort, { once: true }); this.ready.then(p => { signal.removeEventListener('abort', abort); resolve(p); }, e => { signal.removeEventListener('abort', abort); reject(e); }); });
491
+ }
492
+ async open(source, options = {}) {
493
+ if (this.terminal)
494
+ throw new PlayerError('ABORTED', 'Player element is destroyed');
495
+ this.resetQueue();
496
+ this.queueRevision++;
497
+ this.queueItems = [this.queueItem(source, options)];
498
+ return this.activateQueue(0, () => this.autoplay, options);
499
+ }
500
+ async openSource(source, options = {}) {
501
+ if (this.terminal)
502
+ throw new PlayerError('ABORTED', 'Player element is destroyed');
503
+ const version = ++this.sourceVersion;
504
+ this.sourceAbort?.abort();
505
+ const controller = this.sourceAbort = new AbortController();
506
+ const abort = () => controller.abort();
507
+ options.signal?.addEventListener('abort', abort, { once: true });
508
+ if (options.signal?.aborted)
509
+ abort();
510
+ try {
511
+ const core = this.core ?? await this.waitReady(controller.signal);
512
+ if (this.terminal || version !== this.sourceVersion || controller.signal.aborted)
513
+ throw new PlayerError('ABORTED', 'Open aborted');
514
+ this.lastSource = source;
515
+ this.lastOptions = { ...options, signal: undefined };
516
+ this.clearError();
517
+ await core.open(source, { ...options, signal: controller.signal });
518
+ if (version === this.sourceVersion && !controller.signal.aborted && this.core === core) {
519
+ this.sourceName = sourceTitle(source);
520
+ this.sourceNameId = core.state.sourceId;
521
+ this.updateTitle();
522
+ }
523
+ }
524
+ finally {
525
+ options.signal?.removeEventListener('abort', abort);
526
+ }
527
+ }
528
+ close() { this.sourceVersion++; this.sourceAbort?.abort(); this.resetQueue(); this.lastSource = undefined; this.lastOptions = undefined; this.clearError(); return this.core ? this.core.close() : this.terminal ? Promise.reject(new PlayerError('ABORTED', 'Player element is destroyed')) : Promise.resolve(); }
529
+ play() { if (this.queueOperation)
530
+ this.queuePlayIntent = true; return this.core ? this.core.play() : this.ready.then(p => p.play()); }
531
+ pause() { if (this.queueOperation)
532
+ this.queuePlayIntent = false; return this.core ? this.core.pause() : this.ready.then(p => p.pause()); }
533
+ seek(seconds) { return this.ready.then(p => p.seek(seconds)); }
534
+ setVolume(value) { return this.ready.then(p => p.setVolume(value)); }
535
+ setMuted(value) { return this.ready.then(p => p.setMuted(value)); }
536
+ setPlaybackRate(value) { return this.ready.then(p => p.setPlaybackRate(value)); }
537
+ selectAudioTrack(id) { return this.ready.then(p => p.selectAudioTrack(id)); }
538
+ selectSubtitleTrack(id) { return this.ready.then(p => p.selectSubtitleTrack(id)); }
539
+ addSubtitle(file, options) { return this.ready.then(p => p.addSubtitle(file, options)); }
540
+ destroy() {
541
+ if (this.terminal)
542
+ return this.cleanup;
543
+ clearTimeout(this.hideTimer);
544
+ clearTimeout(this.seekPreviewTimer);
545
+ this.terminal = true;
546
+ this.connection++;
547
+ this.sourceVersion++;
548
+ this.sourceAbort?.abort();
549
+ this.resetQueue();
550
+ this.lastSource = undefined;
551
+ this.lastOptions = undefined;
552
+ this.sourceName = '';
553
+ this.sourceNameId = null;
554
+ this.updateTitle();
555
+ this.unsubscribe?.();
556
+ this.resizeObserver?.disconnect();
557
+ document.removeEventListener('fullscreenchange', this.fullscreenChanged);
558
+ document.removeEventListener('pointerdown', this.dismissMenu, true);
559
+ this.rejectReady(new PlayerError('ABORTED', 'Player element is destroyed'));
560
+ const old = this.core;
561
+ this.core = undefined;
562
+ this.cleanup = Promise.all([this.cleanup, this.connecting, old?.destroy()]).then(() => { this.$('surface').replaceChildren(); this.$('controls').hidden = true; this.$('transport').hidden = true; this.$('topbar').hidden = true; this.$('settings').hidden = true; this.$('empty').hidden = true; this.$('diagnostics-overlay').hidden = true; this.$('buffering-indicator').hidden = true; });
563
+ return this.cleanup;
564
+ }
565
+ run(work) { void work.catch(error => { if (!this.terminal && playerError(error).code !== 'ABORTED')
566
+ this.showError(playerError(error).toJSON()); }); }
567
+ componentError(error) { const detail = playerError(error).toJSON(); this.showError(detail); this.dispatchEvent(new CustomEvent('error', { detail })); }
568
+ showError(error) { if (error.code === 'ABORTED')
569
+ return; this.lastFailure = error; this.$('error').hidden = false; this.$('error-text').textContent = error.message; this.$('retry').hidden = !error.retryable; this.$('retry').textContent = error.code === 'AUTOPLAY_BLOCKED' ? this.labels.play : this.labels.retry; this.announce(error.message, false); }
570
+ clearError() { this.lastFailure = undefined; this.$('error').hidden = true; }
571
+ announce(text, visual = true) { this.$('status').classList.toggle('sr', !visual); if (text === this.lastAnnouncement)
572
+ return; this.lastAnnouncement = text; this.$('status').textContent = text; }
573
+ geometry(state) { const ratio = state.mediaInfo.aspectRatio; if (!ratio) {
574
+ this.$('stage').style.removeProperty('--media-aspect');
575
+ return;
576
+ } this.$('stage').style.setProperty('--media-aspect', String(ratio)); if (state.pendingOperation)
577
+ return; const { width, height } = outputDimensions(ratio), key = `${width}x${height}`; if (this.dimensions !== key) {
578
+ this.dimensions = key;
579
+ this.core?.resize(width, height);
580
+ } }
581
+ update(state) {
582
+ // A host using the core directly owns its source list; release ours on replacement.
583
+ if (!this.queueOperation && this.queueSourceId !== null && this.queueSourceId !== state.sourceId)
584
+ this.resetQueue();
585
+ this.renderQueue();
586
+ this.updateSourceLabel();
587
+ if (this.sourceNameId !== state.sourceId) {
588
+ this.sourceName = '';
589
+ this.sourceNameId = null;
590
+ this.updateTitle();
591
+ }
592
+ const labels = this.labels, pending = state.pendingOperation !== null;
593
+ this.$('topbar').hidden = !this.controls;
594
+ const seeking = state.pendingOperation?.kind === 'seeking';
595
+ if (seeking !== this.wasSeeking) {
596
+ this.wasSeeking = seeking;
597
+ clearTimeout(this.seekPreviewTimer);
598
+ if (this.$('shell').classList.contains('idle')) {
599
+ this.$('shell').classList.add('seek-preview');
600
+ if (!seeking)
601
+ this.seekPreviewTimer = setTimeout(() => this.$('shell').classList.remove('seek-preview'), 800);
602
+ }
603
+ else
604
+ this.revealControls();
605
+ }
606
+ const playing = state.status === 'playing';
607
+ if (this.$('shell').classList.contains('playing') !== playing) {
608
+ this.$('shell').classList.toggle('playing', playing);
609
+ if (!this.$('shell').classList.contains('idle') || state.playbackIntent === 'pause' || ['ended', 'error', 'idle'].includes(state.status))
610
+ this.revealControls();
611
+ }
612
+ this.$('controls').hidden = !this.controls || !state.sourceId;
613
+ this.$('transport').hidden = !this.controls || !state.sourceId;
614
+ this.$('empty').hidden = !this.showSourceControls || !!state.sourceId;
615
+ this.$('poster').hidden = !this.poster || !!state.sourceId;
616
+ this.iconButton('play', state.playbackIntent === 'play' ? 'pause' : 'play', state.playbackIntent === 'play' ? labels.pause : labels.play);
617
+ this.$('play').disabled = !state.sourceId || pending;
618
+ this.iconButton('mute', state.muted ? 'muted' : 'volume', state.muted ? labels.unmute : labels.mute);
619
+ this.$('mute').setAttribute('aria-pressed', String(state.muted));
620
+ this.reflected = true;
621
+ this.toggleAttribute('muted', state.muted);
622
+ this.reflected = false;
623
+ if (this.shadowRoot.activeElement !== this.$('volume'))
624
+ this.input('volume').value = String(state.volume);
625
+ this.$('volume').style.setProperty('--volume-progress', `${Number(this.input('volume').value) * 100}%`);
626
+ const window = state.seekable;
627
+ this.input('timeline').disabled = pending || !window?.length;
628
+ for (const id of ['back', 'forward'])
629
+ this.$(id).disabled = pending || !window?.length;
630
+ if (window?.length) {
631
+ this.input('timeline').min = String(window[0].start);
632
+ this.input('timeline').max = String(window.at(-1).end);
633
+ }
634
+ if (!this.dragging) {
635
+ this.input('timeline').value = String(state.currentTime);
636
+ this.input('timeline').setAttribute('aria-valuetext', formatTime(state.currentTime));
637
+ this.$('time').textContent = formatTime(state.currentTime);
638
+ this.$('duration').textContent = state.streamType === 'live' ? labels.live : state.duration === null ? labels.unknown : formatTime(state.duration);
639
+ this.timelineProgress();
640
+ }
641
+ const signature = JSON.stringify([state.audioTracks, state.subtitleTracks]);
642
+ if (signature !== this.trackSignature) {
643
+ this.trackSignature = signature;
644
+ this.trackOptions('audio', state.audioTracks);
645
+ this.trackOptions('subtitles', state.subtitleTracks);
646
+ }
647
+ this.$('speed').value = String(state.playbackRate);
648
+ const buffering = state.status === 'buffering' && state.playbackIntent === 'play' && !pending;
649
+ this.$('buffering-indicator').hidden = !buffering;
650
+ this.$('shell').classList.toggle('buffering', buffering);
651
+ this.bufferedProgress(state);
652
+ const activity = state.pendingOperation?.kind === 'opening' ? labels.loading : state.pendingOperation?.kind === 'switching' ? labels.switching : state.pendingOperation?.kind === 'seeking' ? labels.seeking : state.status === 'buffering' ? labels.buffering : '';
653
+ this.$('busy').hidden = !activity || seeking || buffering;
654
+ this.$('busy').textContent = activity;
655
+ if (!this.lastFailure)
656
+ this.announce(activity || (state.streamType === 'live' && !window?.length ? labels.noWindow : ''), !activity);
657
+ this.geometry(state);
658
+ this.updateDiagnostics();
659
+ }
660
+ setDiagnostics(show) { show = show && this.showDiagnostics && this.controls; this.$('diagnostics-overlay').hidden = !show; this.$('diagnostics-toggle').setAttribute('aria-pressed', String(show)); this.iconButton('diagnostics-toggle', show ? 'eyeOff' : 'eye', this.labels.diagnostics); if (show)
661
+ this.updateDiagnostics(true); }
662
+ updateDiagnostics(force = false) {
663
+ if (this.$('diagnostics-overlay').hidden || !this.core)
664
+ return;
665
+ const now = performance.now();
666
+ if (!force && now - this.diagnosticsUpdated < 500)
667
+ return;
668
+ this.diagnosticsUpdated = now;
669
+ const s = this.core.state, d = this.core.diagnostics, m = s.mediaInfo;
670
+ const lines = [this.labels.diagnostics, `Engine ${s.activeMode ?? '—'} · ${s.automaticSelection ? 'automatic' : 'manual'}`, `State ${s.status}${s.pendingOperation ? ' · ' + s.pendingOperation.kind : ''}`, `Time ${formatTime(s.currentTime)} / ${s.streamType === 'live' ? this.labels.live : s.duration === null ? '—' : formatTime(s.duration)} · ${s.playbackRate}×`, `Video ${m.video?.codec ?? '—'} · ${m.displayWidth ?? '—'} × ${m.displayHeight ?? '—'}`, `Audio ${m.audio?.codec ?? '—'} · ${s.muted ? 'muted' : Math.round(s.volume * 100) + '%'}`];
671
+ for (const [key, value] of Object.entries(d.backend ?? {}))
672
+ if (['string', 'number', 'boolean'].includes(typeof value))
673
+ lines.push(`${key} ${String(value)}`);
674
+ this.$('diagnostics-overlay').textContent = lines.join('\n');
675
+ }
676
+ bufferedProgress(state) {
677
+ const ranges = state.seekable, min = ranges?.[0]?.start ?? 0, max = ranges?.at(-1)?.end ?? 0, span = max - min;
678
+ const layers = span > 0 ? (state.buffered ?? []).filter(r => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start && r.end > min && r.start < max).map(r => { const start = Math.max(0, (r.start - min) / span * 100), end = Math.min(100, (r.end - min) / span * 100); return `linear-gradient(to right,transparent ${start}%,color-mix(in srgb,var(--demuxe-foreground) 21%,transparent) ${start}% ${end}%,transparent ${end}%)`; }) : [];
679
+ this.$('timeline').style.setProperty('--buffered', layers.join(',') || 'linear-gradient(transparent,transparent)');
680
+ }
681
+ timelineProgress() { const input = this.input('timeline'), min = Number(input.min), max = Number(input.max); input.style.setProperty('--progress', `${max > min ? Math.max(0, Math.min(100, (Number(input.value) - min) / (max - min) * 100)) : 0}%`); }
682
+ skip(delta) { const state = this.core?.state, ranges = state?.seekable; if (!state || state.pendingOperation || !ranges?.length)
683
+ return; const target = state.currentTime + delta; const range = ranges.find(r => target <= r.end) ?? ranges.at(-1); this.run(this.seek(Math.max(range.start, Math.min(range.end - .05, target)))); }
684
+ trackOptions(id, list) { const select = this.$(id); select.replaceChildren(new Option(this.labels.automatic, 'auto'), new Option(this.labels.off, '')); for (const t of list)
685
+ select.add(new Option(t.label, t.id)); select.value = list.find(t => t.selected)?.id ?? (list.length ? '' : 'auto'); select.disabled = !list.length; }
686
+ settings(open, restoreFocus = true, trigger = 'settings-toggle') { if (open && trigger === 'open-menu' && !this.showSourceControls)
687
+ return; if (open)
688
+ this.menuTrigger = trigger; this.revealControls(); this.$('settings').hidden = !open; this.$('shell').classList.toggle('menu-open', open); if (open) {
689
+ const source = this.menuTrigger === 'open-menu';
690
+ this.$('source-options').hidden = !source;
691
+ this.$('playback-options').hidden = source;
692
+ this.$('settings-title').textContent = source ? this.labels.open : this.labels.settings;
693
+ this.$('settings').classList.toggle('source-menu', source);
694
+ this.$('settings').scrollTop = 0;
695
+ } this.$('open-menu').setAttribute('aria-expanded', String(open && this.menuTrigger === 'open-menu')); this.iconButton('open-menu', open && this.menuTrigger === 'open-menu' ? 'folderOpen' : 'folder', this.labels.open); this.$('settings-toggle').setAttribute('aria-expanded', String(open && this.menuTrigger === 'settings-toggle')); if (open)
696
+ this.$('settings-close').focus();
697
+ else if (restoreFocus)
698
+ this.$(this.menuTrigger).focus(); }
699
+ fullscreen() { const active = document.fullscreenElement === this; const request = active ? document.exitFullscreen() : this.requestFullscreen?.(); if (!request) {
700
+ this.announce(this.labels.noFullscreen);
701
+ return;
702
+ } void request.then(() => { this.fullscreenChanged(); }, () => this.announce(this.labels.noFullscreen)); }
703
+ iconButton(id, icon, label) { const button = this.$(id); if (button.dataset.icon !== icon) {
704
+ button.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${icons[icon]}</svg>`;
705
+ button.dataset.icon = icon;
706
+ } if (icon === 'back' || icon === 'forward')
707
+ button.querySelector('text').textContent = String(this.seekStep); button.classList.add('icon-button'); button.setAttribute('aria-label', label); button.setAttribute('title', label); }
708
+ labelControls() { this.renderQueue(); this.$('choose-file').textContent = this.queueItems.length ? this.labels.addFiles : this.labels.open; this.updateSourceLabel(); this.$('diagnostics-overlay').setAttribute('aria-label', this.labels.diagnostics); for (const [id, key] of Object.entries({ mute: 'mute', 'settings-toggle': 'settings', 'settings-close': 'closeSettings', fullscreen: 'fullscreen', 'open': 'open', 'open-menu': 'open', 'url-submit': 'openURL', 'retry': 'retry' }))
709
+ this.$(id).textContent = this.labels[key]; for (const [id, icon, key] of [['back', 'back', 'back'], ['forward', 'forward', 'forward'], ['play', 'play', 'play'], ['mute', 'volume', 'mute'], ['settings-toggle', 'settings', 'settings'], ['settings-close', 'close', 'closeSettings'], ['open-menu', 'folder', 'open'], ['diagnostics-toggle', 'eye', 'diagnostics']]) {
710
+ delete this.$(id).dataset.icon;
711
+ this.iconButton(id, id === 'diagnostics-toggle' && this.$(id).getAttribute('aria-pressed') === 'true' ? 'eyeOff' : id === 'open-menu' && this.$(id).getAttribute('aria-expanded') === 'true' ? 'folderOpen' : icon, this.labels[key]);
712
+ } delete this.$('fullscreen').dataset.icon; this.fullscreenChanged(); for (const [id, key] of Object.entries({ timeline: 'seek', volume: 'volume', file: 'open', subtitleFile: 'addSubtitle' }))
713
+ this.$(id).setAttribute('aria-label', this.labels[key]); const opener = this.$('open'); opener.innerHTML = `<svg class="open-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons.folder}</svg><span></span><svg class="open-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14m-5-5 5 5-5 5"/></svg>`; opener.querySelector('span').textContent = this.labels.open; for (const id of ['speed', 'audio', 'subtitles'])
714
+ this.$(id + '-label').textContent = this.labels[id]; this.$('settings-title').textContent = this.menuTrigger === 'open-menu' ? this.labels.open : this.labels.settings; this.$('media-file-label').textContent = this.labels.mediaFile; this.$('subtitle-file-label').textContent = this.labels.subtitleFile; for (const id of ['url', 'format', 'live'])
715
+ this.$(id + '-label').textContent = this.labels[id === 'live' ? 'streamLive' : id]; }
716
+ renderShell() {
717
+ this.shadowRoot.innerHTML = `<style>${styles}</style><section id="shell" class="shell" part="container" aria-label="Media player"><div id="topbar" class="topbar" part="topbar"><span id="title" class="player-title" part="title" hidden></span><span class="space"></span><button id="diagnostics-toggle" aria-pressed="false" aria-controls="diagnostics-overlay"></button><button id="open-menu" aria-expanded="false" aria-controls="settings"></button><button id="settings-toggle" aria-expanded="false" aria-controls="settings"></button><button id="fullscreen"></button></div><div id="stage" class="stage" part="stage" tabindex="0"><div id="surface" class="surface"></div><img id="poster" class="poster" alt="" hidden><div id="empty" class="empty"><button id="open"></button></div><div id="busy" class="busy" aria-hidden="true" hidden></div></div><div id="buffering-indicator" class="buffering-indicator" aria-hidden="true" hidden><span></span></div><div id="transport" part="transport" class="transport" hidden><button id="back" disabled></button><button id="play" class="play" disabled></button><button id="forward" disabled></button></div><div id="controls" class="controls" part="controls"><slot name="before-controls"></slot><input id="timeline" part="timeline" class="timeline" type="range" min="0" max="1" step="0.1" value="0" disabled><div class="times"><span id="time" class="time">0:00</span><div class="row" part="volume"><button id="mute" aria-pressed="false"></button><input id="volume" class="volume" type="range" min="0" max="1" step=".01" value="1"></div><div id="queue-navigation" class="queue-navigation" hidden><button id="previous-file" type="button"></button><span id="queue-count"></span><button id="next-file" type="button"></button></div><span class="space"></span><span id="duration" class="time">—</span></div><slot name="after-controls"></slot></div><section id="settings" class="settings" part="settings" aria-labelledby="settings-title" hidden><header><strong id="settings-title"></strong><button id="settings-close"></button></header><div id="playback-options"><label class="setting-row"><span id="speed-label"></span><select id="speed">${[.5, .75, 1, 1.25, 1.5, 1.75, 2].map(n => `<option value="${n}">${n}×</option>`).join('')}</select></label><label class="setting-row"><span id="audio-label"></span><select id="audio" disabled></select></label><label class="setting-row"><span id="subtitles-label"></span><select id="subtitles" disabled></select></label></div><div id="source-options" hidden><slot id="source-actions" name="source-actions"></slot><div class="media-picker" role="group" aria-labelledby="media-file-label"><span id="media-file-label"></span><span id="current-source"></span><button id="choose-file" type="button" aria-describedby="current-source"></button><input id="file" type="file" multiple hidden></div><section id="queue-section" class="queue-section" aria-labelledby="queue-heading" hidden><div class="queue-header"><strong id="queue-heading"></strong><button id="clear-queue" type="button"></button></div><ol id="queue-list"></ol></section><label class="subtitle-picker"><span id="subtitle-file-label"></span><input id="subtitleFile" type="file" accept=".srt,.ass,.ssa,.vtt"></label><form id="remote"><label><span id="url-label"></span><input id="url" type="url" placeholder="https://…" required></label><label><span id="format-label"></span><select id="format"><option value="file">File</option><option value="hls">HLS</option><option value="dash">DASH</option></select></label><label class="check"><input id="live" type="checkbox"><span id="live-label"></span></label><button id="url-submit" type="submit"></button></form></div></section><div id="error" class="notice" part="error" hidden><span id="error-text"></span><button id="retry"></button></div><pre id="diagnostics-overlay" class="diagnostics-overlay" tabindex="0" role="region" hidden></pre><div id="status" class="status" part="status" role="status" aria-live="polite" aria-atomic="true"></div></section>`;
718
+ this.labelControls();
719
+ this.updateTitle();
720
+ this.updateUtilities();
721
+ this.$('controls').hidden = !this.controls;
722
+ this.$('topbar').hidden = !this.controls;
723
+ this.addEventListener('pointermove', event => { if (event.pointerType !== 'touch')
724
+ this.revealControls(); });
725
+ this.addEventListener('pointerdown', event => { if (this.isScreenPress(event))
726
+ this.stageWasIdle = this.$('shell').classList.contains('idle');
727
+ else
728
+ this.revealControls(); });
729
+ this.addEventListener('focusin', this.revealControls);
730
+ this.addEventListener('focusout', () => { if (!this.$('shell').classList.contains('idle'))
731
+ this.revealControls(); });
732
+ this.addEventListener('pointerleave', event => { if (event.pointerType !== 'mouse' || this.terminal || !this.controls || this.core?.state.status !== 'playing' || !this.core.state.sourceId || this.core.state.pendingOperation || this.dragging || !this.$('settings').hidden || this.shadowRoot?.activeElement?.matches(':focus-visible'))
733
+ return; this.hideControls(); });
734
+ this.$('source-actions').addEventListener('click', event => { if (this.showSourceControls && event.composedPath().some(node => node instanceof HTMLButtonElement)) {
735
+ this.settings(false, false);
736
+ this.$('stage').focus({ preventScroll: true });
737
+ } });
738
+ this.$('open-menu').onclick = () => this.settings(this.$('settings').hidden || this.menuTrigger !== 'open-menu', true, 'open-menu');
739
+ this.$('shell').onclick = event => { if (!this.isScreenPress(event) || !this.core?.state.sourceId || !this.controls)
740
+ return; if (this.stageWasIdle) {
741
+ this.$('stage').focus({ preventScroll: true });
742
+ this.revealControls();
743
+ }
744
+ else
745
+ this.hideControls(true); };
746
+ this.$('remote').onsubmit = event => { event.preventDefault(); if (!this.showSourceControls)
747
+ return; const format = this.$('format').value; this.openFromControls({ url: this.input('url').value, format, ...(format !== 'file' ? { streaming: { live: this.input('live').checked } } : {}) }); };
748
+ this.addEventListener('dragover', event => { if (this.allowFileDrop && event.dataTransfer?.types.includes('Files'))
749
+ event.preventDefault(); });
750
+ this.addEventListener('drop', event => { if (!this.allowFileDrop || !event.dataTransfer?.files.length)
751
+ return; event.preventDefault(); this.addFiles(Array.from(event.dataTransfer.files)); });
752
+ this.$('back').onclick = () => this.skip(-this.seekStep);
753
+ this.$('forward').onclick = () => this.skip(this.seekStep);
754
+ this.$('play').onclick = () => { if (this.core)
755
+ this.run(this.core.state.playbackIntent === 'play' ? this.pause() : this.playFromControls()); };
756
+ this.$('mute').onclick = () => { if (this.core)
757
+ this.run(this.setMuted(!this.core.state.muted)); };
758
+ this.input('volume').oninput = () => this.$('volume').style.setProperty('--volume-progress', `${Number(this.input('volume').value) * 100}%`);
759
+ this.input('volume').onchange = () => this.run(this.setVolume(Number(this.input('volume').value)));
760
+ this.input('timeline').oninput = () => { this.dragging = true; const text = formatTime(Number(this.input('timeline').value)); this.$('time').textContent = text; this.timelineProgress(); this.input('timeline').setAttribute('aria-valuetext', text); };
761
+ this.input('timeline').onchange = () => { const value = Number(this.input('timeline').value); this.dragging = false; this.run(this.seek(value)); };
762
+ this.input('timeline').onpointercancel = () => { this.dragging = false; if (this.core)
763
+ this.update(this.core.state); };
764
+ this.$('settings-toggle').onclick = () => this.settings(this.$('settings').hidden || this.menuTrigger !== 'settings-toggle', true, 'settings-toggle');
765
+ this.$('settings-close').onclick = () => this.settings(false);
766
+ this.$('speed').onchange = () => this.run(this.setPlaybackRate(Number(this.$('speed').value)));
767
+ this.$('audio').onchange = () => this.run(this.selectAudioTrack(this.$('audio').value || null));
768
+ this.$('subtitles').onchange = () => this.run(this.selectSubtitleTrack(this.$('subtitles').value || null));
769
+ this.$('diagnostics-toggle').onclick = () => this.setDiagnostics(this.$('diagnostics-overlay').hidden);
770
+ this.$('fullscreen').onclick = () => this.fullscreen();
771
+ this.$('stage').ondblclick = () => this.fullscreen();
772
+ this.$('choose-file').onclick = () => { if (this.showSourceControls)
773
+ this.input('file').click(); };
774
+ this.$('open').onclick = () => { if (this.showSourceControls)
775
+ this.input('file').click(); };
776
+ this.input('file').onchange = () => { const files = Array.from(this.input('file').files ?? []); this.input('file').value = ''; if (this.showSourceControls)
777
+ this.addFiles(files); };
778
+ this.input('subtitleFile').onchange = () => { const file = this.input('subtitleFile').files?.[0]; this.input('subtitleFile').value = ''; if (file && this.showSourceControls)
779
+ this.run(this.addSubtitle(file)); };
780
+ this.$('previous-file').onclick = () => this.selectQueue(this.queueIndex - 1);
781
+ this.$('next-file').onclick = () => this.selectQueue(this.queueIndex + 1);
782
+ this.$('clear-queue').onclick = () => { if (this.showSourceControls && !this.queueOperation && !this.core?.state.pendingOperation) {
783
+ this.settings(false, false);
784
+ this.$('stage').focus({ preventScroll: true });
785
+ this.run(this.close());
786
+ } };
787
+ this.$('retry').onclick = () => { const error = this.lastFailure; this.clearError(); if (error?.code === 'AUTOPLAY_BLOCKED')
788
+ this.run(this.play());
789
+ else if (this.lastSource)
790
+ this.run(this.queueItems[this.queueIndex]?.source === this.lastSource ? this.activateQueue(this.queueIndex, () => this.autoplay) : this.open(this.lastSource, this.lastOptions)); };
791
+ this.addEventListener('keydown', event => {
792
+ if (event.composedPath().includes(this.$('diagnostics-overlay'))) {
793
+ if (event.key === 'Escape') {
794
+ event.preventDefault();
795
+ this.setDiagnostics(false);
796
+ this.$('diagnostics-toggle').focus();
797
+ }
798
+ return;
799
+ }
800
+ const topbar = event.composedPath().includes(this.$('topbar')), key = shortcut(event, topbar);
801
+ if (topbar && key === ' ')
802
+ event.preventDefault();
803
+ if (event.repeat && this.$('settings').hidden && [' ', 'k', 'm', 'f'].includes(key ?? '')) {
804
+ event.preventDefault();
805
+ return;
806
+ }
807
+ if (!['arrowleft', 'arrowright', 'j', 'l', 'home', 'end', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(key ?? '') || !this.$('shell').classList.contains('idle'))
808
+ this.revealControls();
809
+ if (event.key === 'Escape' && !this.$('settings').hidden) {
810
+ event.preventDefault();
811
+ this.settings(false);
812
+ return;
813
+ }
814
+ if (!this.$('settings').hidden)
815
+ return;
816
+ const p = this.core;
817
+ if (!key || !p)
818
+ return;
819
+ let action;
820
+ const state = p.state;
821
+ if (key === 'f') {
822
+ event.preventDefault();
823
+ this.fullscreen();
824
+ return;
825
+ }
826
+ if (key === '?') {
827
+ event.preventDefault();
828
+ this.settings(true);
829
+ return;
830
+ }
831
+ if (key === 'm')
832
+ action = p.setMuted(!state.muted);
833
+ if (key === 'arrowup' || key === 'arrowdown')
834
+ action = p.setVolume(Math.max(0, Math.min(1, state.volume + (key === 'arrowup' ? .05 : -.05))));
835
+ if (key === '[' || key === ']')
836
+ action = p.setPlaybackRate(Math.max(.5, Math.min(2, state.playbackRate + (key === ']' ? .25 : -.25))));
837
+ if (!state.pendingOperation && state.sourceId) {
838
+ if (key === 'c')
839
+ action = p.subtitleVisible(!state.subtitlesVisible);
840
+ const ranges = state.seekable;
841
+ if (ranges?.length) {
842
+ const start = ranges[0].start, end = Math.max(start, ranges.at(-1).end - .1);
843
+ if (key === 'home')
844
+ action = p.seek(start);
845
+ if (key === 'end')
846
+ action = p.seek(end);
847
+ if (/^[0-9]$/.test(key))
848
+ action = p.seek(start + (end - start) * Number(key) / 10);
849
+ }
850
+ if (key === ' ' || key === 'k')
851
+ action = state.playbackIntent === 'play' ? this.pause() : this.playFromControls();
852
+ const delta = key === 'arrowleft' ? -5 : key === 'arrowright' ? 5 : key === 'j' ? -this.seekStep : key === 'l' ? this.seekStep : 0;
853
+ const window = state.seekable;
854
+ if (delta && window?.length)
855
+ action = p.seek(Math.max(window[0].start, Math.min(window.at(-1).end - .05, state.currentTime + delta)));
856
+ }
857
+ if (action) {
858
+ event.preventDefault();
859
+ this.run(action);
860
+ }
861
+ });
862
+ }
863
+ }
864
+ export function definePlayerElement(name = 'demuxe-player') {
865
+ if (typeof customElements === 'undefined')
866
+ throw new PlayerError('INVALID_ARGUMENT', 'Custom element registration requires a browser');
867
+ const existing = customElements.get(name);
868
+ if (existing && existing !== DemuxePlayerElement)
869
+ throw new PlayerError('INVALID_ARGUMENT', `Custom element ${name} is already registered with another implementation`);
870
+ if (!existing)
871
+ customElements.define(name, DemuxePlayerElement);
872
+ return DemuxePlayerElement;
873
+ }