@helios-project/player 0.48.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.
- package/README.md +148 -0
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +169 -0
- package/dist/controllers.d.ts +91 -0
- package/dist/controllers.js +224 -0
- package/dist/features/audio-utils.d.ts +10 -0
- package/dist/features/audio-utils.js +66 -0
- package/dist/features/dom-capture.d.ts +1 -0
- package/dist/features/dom-capture.js +253 -0
- package/dist/features/exporter.d.ts +18 -0
- package/dist/features/exporter.js +228 -0
- package/dist/features/srt-parser.d.ts +7 -0
- package/dist/features/srt-parser.js +75 -0
- package/dist/features/text-tracks.d.ts +40 -0
- package/dist/features/text-tracks.js +99 -0
- package/dist/helios-player.bundle.mjs +7775 -0
- package/dist/helios-player.global.js +633 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +1679 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1679 @@
|
|
|
1
|
+
import { DirectController, BridgeController } from "./controllers";
|
|
2
|
+
import { ClientSideExporter } from "./features/exporter";
|
|
3
|
+
import { HeliosTextTrack, HeliosTextTrackList, CueClass } from "./features/text-tracks";
|
|
4
|
+
import { parseSRT } from "./features/srt-parser";
|
|
5
|
+
export { ClientSideExporter };
|
|
6
|
+
class StaticTimeRange {
|
|
7
|
+
startVal;
|
|
8
|
+
endVal;
|
|
9
|
+
constructor(startVal, endVal) {
|
|
10
|
+
this.startVal = startVal;
|
|
11
|
+
this.endVal = endVal;
|
|
12
|
+
}
|
|
13
|
+
get length() {
|
|
14
|
+
return this.endVal > 0 ? 1 : 0;
|
|
15
|
+
}
|
|
16
|
+
start(index) {
|
|
17
|
+
if (index !== 0 || this.length === 0)
|
|
18
|
+
throw new Error("IndexSizeError");
|
|
19
|
+
return this.startVal;
|
|
20
|
+
}
|
|
21
|
+
end(index) {
|
|
22
|
+
if (index !== 0 || this.length === 0)
|
|
23
|
+
throw new Error("IndexSizeError");
|
|
24
|
+
return this.endVal;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const template = document.createElement("template");
|
|
28
|
+
template.innerHTML = `
|
|
29
|
+
<style>
|
|
30
|
+
:host {
|
|
31
|
+
display: block;
|
|
32
|
+
width: 100%;
|
|
33
|
+
aspect-ratio: 16 / 9;
|
|
34
|
+
background-color: #f0f0f0;
|
|
35
|
+
position: relative;
|
|
36
|
+
font-family: var(--helios-font-family, sans-serif);
|
|
37
|
+
|
|
38
|
+
/* CSS Variables for Theming */
|
|
39
|
+
--helios-controls-bg: rgba(0, 0, 0, 0.6);
|
|
40
|
+
--helios-text-color: white;
|
|
41
|
+
--helios-accent-color: #007bff;
|
|
42
|
+
--helios-range-track-color: #555;
|
|
43
|
+
--helios-range-selected-color: rgba(255, 255, 255, 0.2);
|
|
44
|
+
--helios-range-unselected-color: var(--helios-range-track-color);
|
|
45
|
+
--helios-font-family: sans-serif;
|
|
46
|
+
}
|
|
47
|
+
iframe {
|
|
48
|
+
width: 100%;
|
|
49
|
+
height: 100%;
|
|
50
|
+
border: none;
|
|
51
|
+
}
|
|
52
|
+
.controls {
|
|
53
|
+
position: absolute;
|
|
54
|
+
bottom: 0;
|
|
55
|
+
left: 0;
|
|
56
|
+
right: 0;
|
|
57
|
+
background: var(--helios-controls-bg);
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
padding: 8px;
|
|
61
|
+
color: var(--helios-text-color);
|
|
62
|
+
transition: opacity 0.3s;
|
|
63
|
+
z-index: 2;
|
|
64
|
+
}
|
|
65
|
+
:host(:not([controls])) .controls {
|
|
66
|
+
display: none;
|
|
67
|
+
pointer-events: none;
|
|
68
|
+
}
|
|
69
|
+
.play-pause-btn {
|
|
70
|
+
background: none;
|
|
71
|
+
border: none;
|
|
72
|
+
color: var(--helios-text-color);
|
|
73
|
+
font-size: 24px;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
width: 40px;
|
|
76
|
+
height: 40px;
|
|
77
|
+
}
|
|
78
|
+
.volume-control {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
margin-right: 8px;
|
|
82
|
+
}
|
|
83
|
+
.volume-btn {
|
|
84
|
+
background: none;
|
|
85
|
+
border: none;
|
|
86
|
+
color: var(--helios-text-color);
|
|
87
|
+
font-size: 20px;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
width: 32px;
|
|
90
|
+
height: 32px;
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
}
|
|
95
|
+
.volume-slider {
|
|
96
|
+
width: 60px;
|
|
97
|
+
margin-left: 4px;
|
|
98
|
+
height: 4px;
|
|
99
|
+
-webkit-appearance: none;
|
|
100
|
+
background: var(--helios-range-track-color);
|
|
101
|
+
outline: none;
|
|
102
|
+
border-radius: 2px;
|
|
103
|
+
}
|
|
104
|
+
.volume-slider::-webkit-slider-thumb {
|
|
105
|
+
-webkit-appearance: none;
|
|
106
|
+
appearance: none;
|
|
107
|
+
width: 12px;
|
|
108
|
+
height: 12px;
|
|
109
|
+
background: var(--helios-text-color);
|
|
110
|
+
cursor: pointer;
|
|
111
|
+
border-radius: 50%;
|
|
112
|
+
}
|
|
113
|
+
.export-btn {
|
|
114
|
+
background-color: var(--helios-accent-color);
|
|
115
|
+
border: none;
|
|
116
|
+
color: var(--helios-text-color);
|
|
117
|
+
font-size: 14px;
|
|
118
|
+
font-weight: bold;
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
padding: 6px 12px;
|
|
121
|
+
margin: 0 10px;
|
|
122
|
+
border-radius: 4px;
|
|
123
|
+
}
|
|
124
|
+
.export-btn:hover {
|
|
125
|
+
filter: brightness(0.9);
|
|
126
|
+
}
|
|
127
|
+
.export-btn:disabled {
|
|
128
|
+
background-color: #666;
|
|
129
|
+
cursor: not-allowed;
|
|
130
|
+
}
|
|
131
|
+
.scrubber-wrapper {
|
|
132
|
+
flex-grow: 1;
|
|
133
|
+
margin: 0 16px;
|
|
134
|
+
position: relative;
|
|
135
|
+
height: 8px;
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
}
|
|
139
|
+
.scrubber {
|
|
140
|
+
width: 100%;
|
|
141
|
+
height: 100%;
|
|
142
|
+
margin: 0;
|
|
143
|
+
position: relative;
|
|
144
|
+
z-index: 1;
|
|
145
|
+
-webkit-appearance: none;
|
|
146
|
+
background: var(--helios-range-track-color);
|
|
147
|
+
outline: none;
|
|
148
|
+
opacity: 0.9;
|
|
149
|
+
transition: opacity .2s;
|
|
150
|
+
}
|
|
151
|
+
.scrubber::-webkit-slider-thumb {
|
|
152
|
+
-webkit-appearance: none;
|
|
153
|
+
appearance: none;
|
|
154
|
+
width: 16px;
|
|
155
|
+
height: 16px;
|
|
156
|
+
background: var(--helios-accent-color);
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
border-radius: 50%;
|
|
159
|
+
}
|
|
160
|
+
.scrubber-tooltip {
|
|
161
|
+
position: absolute;
|
|
162
|
+
bottom: 100%;
|
|
163
|
+
transform: translateX(-50%);
|
|
164
|
+
background: rgba(0, 0, 0, 0.8);
|
|
165
|
+
color: white;
|
|
166
|
+
padding: 4px 8px;
|
|
167
|
+
border-radius: 4px;
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
pointer-events: none;
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
z-index: 10;
|
|
172
|
+
margin-bottom: 8px;
|
|
173
|
+
}
|
|
174
|
+
.scrubber-tooltip.hidden {
|
|
175
|
+
display: none;
|
|
176
|
+
}
|
|
177
|
+
.markers-container {
|
|
178
|
+
position: absolute;
|
|
179
|
+
inset: 0;
|
|
180
|
+
pointer-events: none;
|
|
181
|
+
z-index: 2;
|
|
182
|
+
}
|
|
183
|
+
.marker {
|
|
184
|
+
position: absolute;
|
|
185
|
+
width: 4px;
|
|
186
|
+
height: 12px;
|
|
187
|
+
background-color: var(--helios-accent-color);
|
|
188
|
+
transform: translateX(-50%);
|
|
189
|
+
cursor: pointer;
|
|
190
|
+
pointer-events: auto;
|
|
191
|
+
border-radius: 2px;
|
|
192
|
+
top: -2px;
|
|
193
|
+
transition: transform 0.1s;
|
|
194
|
+
}
|
|
195
|
+
.marker:hover {
|
|
196
|
+
transform: translateX(-50%) scale(1.2);
|
|
197
|
+
z-index: 10;
|
|
198
|
+
}
|
|
199
|
+
.time-display {
|
|
200
|
+
min-width: 90px;
|
|
201
|
+
text-align: center;
|
|
202
|
+
}
|
|
203
|
+
.status-overlay {
|
|
204
|
+
position: absolute;
|
|
205
|
+
inset: 0;
|
|
206
|
+
background: rgba(0, 0, 0, 0.8);
|
|
207
|
+
backdrop-filter: blur(4px);
|
|
208
|
+
color: white;
|
|
209
|
+
display: flex;
|
|
210
|
+
flex-direction: column;
|
|
211
|
+
align-items: center;
|
|
212
|
+
justify-content: center;
|
|
213
|
+
z-index: 10;
|
|
214
|
+
transition: opacity 0.3s;
|
|
215
|
+
}
|
|
216
|
+
.status-overlay.hidden {
|
|
217
|
+
opacity: 0;
|
|
218
|
+
pointer-events: none;
|
|
219
|
+
}
|
|
220
|
+
.error-msg {
|
|
221
|
+
color: #ff6b6b;
|
|
222
|
+
margin-bottom: 10px;
|
|
223
|
+
font-size: 16px;
|
|
224
|
+
font-weight: bold;
|
|
225
|
+
}
|
|
226
|
+
.retry-btn {
|
|
227
|
+
background-color: #ff6b6b;
|
|
228
|
+
border: none;
|
|
229
|
+
color: white;
|
|
230
|
+
padding: 8px 16px;
|
|
231
|
+
border-radius: 4px;
|
|
232
|
+
cursor: pointer;
|
|
233
|
+
font-size: 14px;
|
|
234
|
+
margin-top: 10px;
|
|
235
|
+
}
|
|
236
|
+
.retry-btn:hover {
|
|
237
|
+
background-color: #ff5252;
|
|
238
|
+
}
|
|
239
|
+
.speed-selector {
|
|
240
|
+
background: rgba(0, 0, 0, 0.4);
|
|
241
|
+
color: var(--helios-text-color);
|
|
242
|
+
border: 1px solid var(--helios-range-track-color);
|
|
243
|
+
border-radius: 4px;
|
|
244
|
+
padding: 4px 8px;
|
|
245
|
+
margin-left: 8px;
|
|
246
|
+
font-size: 12px;
|
|
247
|
+
cursor: pointer;
|
|
248
|
+
}
|
|
249
|
+
.speed-selector:hover {
|
|
250
|
+
background: rgba(0, 0, 0, 0.6);
|
|
251
|
+
}
|
|
252
|
+
.speed-selector:focus {
|
|
253
|
+
outline: none;
|
|
254
|
+
border-color: var(--helios-accent-color);
|
|
255
|
+
}
|
|
256
|
+
.fullscreen-btn {
|
|
257
|
+
background: none;
|
|
258
|
+
border: none;
|
|
259
|
+
color: var(--helios-text-color);
|
|
260
|
+
font-size: 20px;
|
|
261
|
+
cursor: pointer;
|
|
262
|
+
width: 40px;
|
|
263
|
+
height: 40px;
|
|
264
|
+
margin-left: 8px;
|
|
265
|
+
}
|
|
266
|
+
.fullscreen-btn:hover {
|
|
267
|
+
color: var(--helios-accent-color);
|
|
268
|
+
}
|
|
269
|
+
.captions-container {
|
|
270
|
+
position: absolute;
|
|
271
|
+
bottom: 60px;
|
|
272
|
+
left: 50%;
|
|
273
|
+
transform: translateX(-50%);
|
|
274
|
+
width: 80%;
|
|
275
|
+
text-align: center;
|
|
276
|
+
pointer-events: none;
|
|
277
|
+
display: flex;
|
|
278
|
+
flex-direction: column;
|
|
279
|
+
align-items: center;
|
|
280
|
+
gap: 4px;
|
|
281
|
+
z-index: 5;
|
|
282
|
+
}
|
|
283
|
+
.caption-cue {
|
|
284
|
+
background: rgba(0, 0, 0, 0.7);
|
|
285
|
+
color: white;
|
|
286
|
+
padding: 4px 8px;
|
|
287
|
+
border-radius: 4px;
|
|
288
|
+
font-size: 16px;
|
|
289
|
+
text-shadow: 0 1px 2px black;
|
|
290
|
+
white-space: pre-wrap;
|
|
291
|
+
}
|
|
292
|
+
.cc-btn {
|
|
293
|
+
background: none;
|
|
294
|
+
border: none;
|
|
295
|
+
color: var(--helios-text-color);
|
|
296
|
+
font-size: 14px;
|
|
297
|
+
font-weight: bold;
|
|
298
|
+
cursor: pointer;
|
|
299
|
+
width: 32px;
|
|
300
|
+
height: 32px;
|
|
301
|
+
display: flex;
|
|
302
|
+
align-items: center;
|
|
303
|
+
justify-content: center;
|
|
304
|
+
margin-left: 4px;
|
|
305
|
+
opacity: 0.7;
|
|
306
|
+
}
|
|
307
|
+
.cc-btn:hover {
|
|
308
|
+
opacity: 1;
|
|
309
|
+
}
|
|
310
|
+
.cc-btn.active {
|
|
311
|
+
opacity: 1;
|
|
312
|
+
color: var(--helios-accent-color);
|
|
313
|
+
border-bottom: 2px solid var(--helios-accent-color);
|
|
314
|
+
}
|
|
315
|
+
.poster-container {
|
|
316
|
+
position: absolute;
|
|
317
|
+
inset: 0;
|
|
318
|
+
background-color: black;
|
|
319
|
+
z-index: 5;
|
|
320
|
+
display: flex;
|
|
321
|
+
align-items: center;
|
|
322
|
+
justify-content: center;
|
|
323
|
+
cursor: pointer;
|
|
324
|
+
transition: opacity 0.3s;
|
|
325
|
+
}
|
|
326
|
+
.poster-container.hidden {
|
|
327
|
+
opacity: 0;
|
|
328
|
+
pointer-events: none;
|
|
329
|
+
}
|
|
330
|
+
.poster-image {
|
|
331
|
+
position: absolute;
|
|
332
|
+
inset: 0;
|
|
333
|
+
width: 100%;
|
|
334
|
+
height: 100%;
|
|
335
|
+
object-fit: cover;
|
|
336
|
+
opacity: 0.6;
|
|
337
|
+
}
|
|
338
|
+
.big-play-btn {
|
|
339
|
+
position: relative;
|
|
340
|
+
z-index: 10;
|
|
341
|
+
background: rgba(0, 0, 0, 0.7);
|
|
342
|
+
border: 2px solid white;
|
|
343
|
+
border-radius: 50%;
|
|
344
|
+
width: 80px;
|
|
345
|
+
height: 80px;
|
|
346
|
+
color: white;
|
|
347
|
+
font-size: 40px;
|
|
348
|
+
display: flex;
|
|
349
|
+
align-items: center;
|
|
350
|
+
justify-content: center;
|
|
351
|
+
cursor: pointer;
|
|
352
|
+
transition: transform 0.2s;
|
|
353
|
+
}
|
|
354
|
+
.big-play-btn:hover {
|
|
355
|
+
transform: scale(1.1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.click-layer {
|
|
359
|
+
position: absolute;
|
|
360
|
+
inset: 0;
|
|
361
|
+
z-index: 1;
|
|
362
|
+
background: transparent;
|
|
363
|
+
}
|
|
364
|
+
:host([interactive]) .click-layer {
|
|
365
|
+
pointer-events: none;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* Responsive Layouts */
|
|
369
|
+
.controls.layout-compact .volume-slider {
|
|
370
|
+
display: none;
|
|
371
|
+
}
|
|
372
|
+
.controls.layout-tiny .volume-slider {
|
|
373
|
+
display: none;
|
|
374
|
+
}
|
|
375
|
+
.controls.layout-tiny .speed-selector {
|
|
376
|
+
display: none;
|
|
377
|
+
}
|
|
378
|
+
slot {
|
|
379
|
+
display: none;
|
|
380
|
+
}
|
|
381
|
+
</style>
|
|
382
|
+
<slot></slot>
|
|
383
|
+
<div class="status-overlay hidden" part="overlay">
|
|
384
|
+
<div class="status-text">Connecting...</div>
|
|
385
|
+
<button class="retry-btn" style="display: none">Retry</button>
|
|
386
|
+
</div>
|
|
387
|
+
<div class="poster-container hidden" part="poster">
|
|
388
|
+
<img class="poster-image" alt="Video poster" />
|
|
389
|
+
<div class="big-play-btn" aria-label="Play video">▶</div>
|
|
390
|
+
</div>
|
|
391
|
+
<iframe part="iframe" sandbox="allow-scripts allow-same-origin" title="Helios Composition Preview"></iframe>
|
|
392
|
+
<div class="click-layer" part="click-layer"></div>
|
|
393
|
+
<div class="captions-container" part="captions"></div>
|
|
394
|
+
<div class="controls" role="toolbar" aria-label="Playback Controls">
|
|
395
|
+
<button class="play-pause-btn" part="play-pause-button" aria-label="Play">▶</button>
|
|
396
|
+
<div class="volume-control">
|
|
397
|
+
<button class="volume-btn" part="volume-button" aria-label="Mute">🔊</button>
|
|
398
|
+
<input type="range" class="volume-slider" min="0" max="1" step="0.05" value="1" part="volume-slider" aria-label="Volume">
|
|
399
|
+
</div>
|
|
400
|
+
<button class="cc-btn" part="cc-button" aria-label="Toggle Captions">CC</button>
|
|
401
|
+
<button class="export-btn" part="export-button" aria-label="Export video">Export</button>
|
|
402
|
+
<select class="speed-selector" part="speed-selector" aria-label="Playback speed">
|
|
403
|
+
<option value="0.25">0.25x</option>
|
|
404
|
+
<option value="0.5">0.5x</option>
|
|
405
|
+
<option value="1" selected>1x</option>
|
|
406
|
+
<option value="2">2x</option>
|
|
407
|
+
</select>
|
|
408
|
+
<div class="scrubber-wrapper">
|
|
409
|
+
<div class="scrubber-tooltip hidden" part="tooltip"></div>
|
|
410
|
+
<div class="markers-container" part="markers"></div>
|
|
411
|
+
<input type="range" class="scrubber" min="0" value="0" step="1" part="scrubber" aria-label="Seek time">
|
|
412
|
+
</div>
|
|
413
|
+
<div class="time-display" part="time-display">0.00 / 0.00</div>
|
|
414
|
+
<button class="fullscreen-btn" part="fullscreen-button" aria-label="Toggle fullscreen">⛶</button>
|
|
415
|
+
</div>
|
|
416
|
+
`;
|
|
417
|
+
export class HeliosPlayer extends HTMLElement {
|
|
418
|
+
iframe;
|
|
419
|
+
_textTracks;
|
|
420
|
+
_domTracks = new Map();
|
|
421
|
+
playPauseBtn;
|
|
422
|
+
volumeBtn;
|
|
423
|
+
volumeSlider;
|
|
424
|
+
scrubber;
|
|
425
|
+
scrubberWrapper;
|
|
426
|
+
scrubberTooltip;
|
|
427
|
+
markersContainer;
|
|
428
|
+
timeDisplay;
|
|
429
|
+
exportBtn;
|
|
430
|
+
overlay;
|
|
431
|
+
statusText;
|
|
432
|
+
retryBtn;
|
|
433
|
+
retryAction;
|
|
434
|
+
speedSelector;
|
|
435
|
+
fullscreenBtn;
|
|
436
|
+
captionsContainer;
|
|
437
|
+
ccBtn;
|
|
438
|
+
showCaptions = false;
|
|
439
|
+
clickLayer;
|
|
440
|
+
posterContainer;
|
|
441
|
+
posterImage;
|
|
442
|
+
bigPlayBtn;
|
|
443
|
+
pendingSrc = null;
|
|
444
|
+
isLoaded = false;
|
|
445
|
+
resizeObserver;
|
|
446
|
+
controller = null;
|
|
447
|
+
// Keep track if we have direct access (optional, mainly for debugging/logging)
|
|
448
|
+
directHelios = null;
|
|
449
|
+
unsubscribe = null;
|
|
450
|
+
connectionInterval = null;
|
|
451
|
+
abortController = null;
|
|
452
|
+
isExporting = false;
|
|
453
|
+
isScrubbing = false;
|
|
454
|
+
wasPlayingBeforeScrub = false;
|
|
455
|
+
lastState = null;
|
|
456
|
+
pendingProps = null;
|
|
457
|
+
_error = null;
|
|
458
|
+
// --- Standard Media API States ---
|
|
459
|
+
static HAVE_NOTHING = 0;
|
|
460
|
+
static HAVE_METADATA = 1;
|
|
461
|
+
static HAVE_CURRENT_DATA = 2;
|
|
462
|
+
static HAVE_FUTURE_DATA = 3;
|
|
463
|
+
static HAVE_ENOUGH_DATA = 4;
|
|
464
|
+
static NETWORK_EMPTY = 0;
|
|
465
|
+
static NETWORK_IDLE = 1;
|
|
466
|
+
static NETWORK_LOADING = 2;
|
|
467
|
+
static NETWORK_NO_SOURCE = 3;
|
|
468
|
+
_readyState = HeliosPlayer.HAVE_NOTHING;
|
|
469
|
+
_networkState = HeliosPlayer.NETWORK_EMPTY;
|
|
470
|
+
get readyState() {
|
|
471
|
+
return this._readyState;
|
|
472
|
+
}
|
|
473
|
+
get networkState() {
|
|
474
|
+
return this._networkState;
|
|
475
|
+
}
|
|
476
|
+
get error() {
|
|
477
|
+
return this._error;
|
|
478
|
+
}
|
|
479
|
+
get currentSrc() {
|
|
480
|
+
return this.src;
|
|
481
|
+
}
|
|
482
|
+
// --- Standard Media API ---
|
|
483
|
+
canPlayType(type) {
|
|
484
|
+
// We strictly play Helios compositions, not standard video MIME types.
|
|
485
|
+
// Return empty string to be spec-compliant for video/mp4 etc.
|
|
486
|
+
return "";
|
|
487
|
+
}
|
|
488
|
+
get defaultMuted() {
|
|
489
|
+
return this.hasAttribute("muted");
|
|
490
|
+
}
|
|
491
|
+
set defaultMuted(val) {
|
|
492
|
+
if (val) {
|
|
493
|
+
this.setAttribute("muted", "");
|
|
494
|
+
}
|
|
495
|
+
else {
|
|
496
|
+
this.removeAttribute("muted");
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
_defaultPlaybackRate = 1.0;
|
|
500
|
+
get defaultPlaybackRate() {
|
|
501
|
+
return this._defaultPlaybackRate;
|
|
502
|
+
}
|
|
503
|
+
set defaultPlaybackRate(val) {
|
|
504
|
+
if (this._defaultPlaybackRate !== val) {
|
|
505
|
+
this._defaultPlaybackRate = val;
|
|
506
|
+
this.dispatchEvent(new Event("ratechange"));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
_preservesPitch = true;
|
|
510
|
+
get preservesPitch() {
|
|
511
|
+
return this._preservesPitch;
|
|
512
|
+
}
|
|
513
|
+
set preservesPitch(val) {
|
|
514
|
+
this._preservesPitch = val;
|
|
515
|
+
}
|
|
516
|
+
get srcObject() {
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
set srcObject(val) {
|
|
520
|
+
console.warn("HeliosPlayer does not support srcObject");
|
|
521
|
+
}
|
|
522
|
+
get crossOrigin() {
|
|
523
|
+
return this.getAttribute("crossorigin");
|
|
524
|
+
}
|
|
525
|
+
set crossOrigin(val) {
|
|
526
|
+
if (val !== null) {
|
|
527
|
+
this.setAttribute("crossorigin", val);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
this.removeAttribute("crossorigin");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
get seeking() {
|
|
534
|
+
// Return internal scrubbing state as seeking
|
|
535
|
+
return this.isScrubbing;
|
|
536
|
+
}
|
|
537
|
+
get buffered() {
|
|
538
|
+
return new StaticTimeRange(0, this.duration);
|
|
539
|
+
}
|
|
540
|
+
get seekable() {
|
|
541
|
+
return new StaticTimeRange(0, this.duration);
|
|
542
|
+
}
|
|
543
|
+
get played() {
|
|
544
|
+
// Standard Media API: played range matches duration
|
|
545
|
+
return new StaticTimeRange(0, this.duration);
|
|
546
|
+
}
|
|
547
|
+
get videoWidth() {
|
|
548
|
+
if (this.controller) {
|
|
549
|
+
const state = this.controller.getState();
|
|
550
|
+
if (state.width)
|
|
551
|
+
return state.width;
|
|
552
|
+
}
|
|
553
|
+
return parseFloat(this.getAttribute("width") || "0");
|
|
554
|
+
}
|
|
555
|
+
get videoHeight() {
|
|
556
|
+
if (this.controller) {
|
|
557
|
+
const state = this.controller.getState();
|
|
558
|
+
if (state.height)
|
|
559
|
+
return state.height;
|
|
560
|
+
}
|
|
561
|
+
return parseFloat(this.getAttribute("height") || "0");
|
|
562
|
+
}
|
|
563
|
+
get currentTime() {
|
|
564
|
+
if (!this.controller)
|
|
565
|
+
return 0;
|
|
566
|
+
const s = this.controller.getState();
|
|
567
|
+
return s.fps ? s.currentFrame / s.fps : 0;
|
|
568
|
+
}
|
|
569
|
+
set currentTime(val) {
|
|
570
|
+
if (this.controller) {
|
|
571
|
+
const s = this.controller.getState();
|
|
572
|
+
if (s.fps) {
|
|
573
|
+
// Dispatch events to satisfy Standard Media API expectations
|
|
574
|
+
this.dispatchEvent(new Event("seeking"));
|
|
575
|
+
this.controller.seek(Math.floor(val * s.fps));
|
|
576
|
+
this.dispatchEvent(new Event("seeked"));
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
get currentFrame() {
|
|
581
|
+
return this.controller ? this.controller.getState().currentFrame : 0;
|
|
582
|
+
}
|
|
583
|
+
set currentFrame(val) {
|
|
584
|
+
if (this.controller) {
|
|
585
|
+
// Dispatch events to satisfy Standard Media API expectations
|
|
586
|
+
this.dispatchEvent(new Event("seeking"));
|
|
587
|
+
this.controller.seek(Math.floor(val));
|
|
588
|
+
this.dispatchEvent(new Event("seeked"));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
get duration() {
|
|
592
|
+
return this.controller ? this.controller.getState().duration : 0;
|
|
593
|
+
}
|
|
594
|
+
get paused() {
|
|
595
|
+
return this.controller ? !this.controller.getState().isPlaying : true;
|
|
596
|
+
}
|
|
597
|
+
get ended() {
|
|
598
|
+
if (!this.controller)
|
|
599
|
+
return false;
|
|
600
|
+
const s = this.controller.getState();
|
|
601
|
+
return s.currentFrame >= s.duration * s.fps - 1;
|
|
602
|
+
}
|
|
603
|
+
get volume() {
|
|
604
|
+
return this.controller ? this.controller.getState().volume ?? 1 : 1;
|
|
605
|
+
}
|
|
606
|
+
set volume(val) {
|
|
607
|
+
if (this.controller) {
|
|
608
|
+
this.controller.setAudioVolume(Math.max(0, Math.min(1, val)));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
get muted() {
|
|
612
|
+
return this.controller ? !!this.controller.getState().muted : false;
|
|
613
|
+
}
|
|
614
|
+
set muted(val) {
|
|
615
|
+
if (this.controller) {
|
|
616
|
+
this.controller.setAudioMuted(val);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
get interactive() {
|
|
620
|
+
return this.hasAttribute("interactive");
|
|
621
|
+
}
|
|
622
|
+
set interactive(val) {
|
|
623
|
+
if (val) {
|
|
624
|
+
this.setAttribute("interactive", "");
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
this.removeAttribute("interactive");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
get playbackRate() {
|
|
631
|
+
return this.controller ? this.controller.getState().playbackRate ?? 1 : 1;
|
|
632
|
+
}
|
|
633
|
+
set playbackRate(val) {
|
|
634
|
+
if (this.controller) {
|
|
635
|
+
this.controller.setPlaybackRate(val);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
get fps() {
|
|
639
|
+
return this.controller ? this.controller.getState().fps : 0;
|
|
640
|
+
}
|
|
641
|
+
get src() {
|
|
642
|
+
return this.getAttribute("src") || "";
|
|
643
|
+
}
|
|
644
|
+
set src(val) {
|
|
645
|
+
this.setAttribute("src", val);
|
|
646
|
+
}
|
|
647
|
+
get autoplay() {
|
|
648
|
+
return this.hasAttribute("autoplay");
|
|
649
|
+
}
|
|
650
|
+
set autoplay(val) {
|
|
651
|
+
if (val) {
|
|
652
|
+
this.setAttribute("autoplay", "");
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
this.removeAttribute("autoplay");
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
get loop() {
|
|
659
|
+
return this.hasAttribute("loop");
|
|
660
|
+
}
|
|
661
|
+
set loop(val) {
|
|
662
|
+
if (val) {
|
|
663
|
+
this.setAttribute("loop", "");
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
this.removeAttribute("loop");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
get controls() {
|
|
670
|
+
return this.hasAttribute("controls");
|
|
671
|
+
}
|
|
672
|
+
set controls(val) {
|
|
673
|
+
if (val) {
|
|
674
|
+
this.setAttribute("controls", "");
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
this.removeAttribute("controls");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
get poster() {
|
|
681
|
+
return this.getAttribute("poster") || "";
|
|
682
|
+
}
|
|
683
|
+
set poster(val) {
|
|
684
|
+
this.setAttribute("poster", val);
|
|
685
|
+
}
|
|
686
|
+
get preload() {
|
|
687
|
+
return this.getAttribute("preload") || "auto";
|
|
688
|
+
}
|
|
689
|
+
set preload(val) {
|
|
690
|
+
this.setAttribute("preload", val);
|
|
691
|
+
}
|
|
692
|
+
get sandbox() {
|
|
693
|
+
return this.getAttribute("sandbox") || "allow-scripts allow-same-origin";
|
|
694
|
+
}
|
|
695
|
+
set sandbox(val) {
|
|
696
|
+
this.setAttribute("sandbox", val);
|
|
697
|
+
}
|
|
698
|
+
async play() {
|
|
699
|
+
if (!this.isLoaded) {
|
|
700
|
+
this.setAttribute("autoplay", "");
|
|
701
|
+
this.load();
|
|
702
|
+
}
|
|
703
|
+
else if (this.controller) {
|
|
704
|
+
this.controller.play();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
load() {
|
|
708
|
+
if (this.pendingSrc) {
|
|
709
|
+
const src = this.pendingSrc;
|
|
710
|
+
this.pendingSrc = null;
|
|
711
|
+
this.loadIframe(src);
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
const src = this.getAttribute("src");
|
|
715
|
+
if (src) {
|
|
716
|
+
this.loadIframe(src);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
pause() {
|
|
721
|
+
if (this.controller) {
|
|
722
|
+
this.controller.pause();
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
static get observedAttributes() {
|
|
726
|
+
return ["src", "width", "height", "autoplay", "loop", "controls", "export-format", "input-props", "poster", "muted", "interactive", "preload", "controlslist", "sandbox", "export-caption-mode"];
|
|
727
|
+
}
|
|
728
|
+
constructor() {
|
|
729
|
+
super();
|
|
730
|
+
this.attachShadow({ mode: "open" });
|
|
731
|
+
this.shadowRoot.appendChild(template.content.cloneNode(true));
|
|
732
|
+
this.iframe = this.shadowRoot.querySelector("iframe");
|
|
733
|
+
this.playPauseBtn = this.shadowRoot.querySelector(".play-pause-btn");
|
|
734
|
+
this.volumeBtn = this.shadowRoot.querySelector(".volume-btn");
|
|
735
|
+
this.volumeSlider = this.shadowRoot.querySelector(".volume-slider");
|
|
736
|
+
this.scrubber = this.shadowRoot.querySelector(".scrubber");
|
|
737
|
+
this.scrubberWrapper = this.shadowRoot.querySelector(".scrubber-wrapper");
|
|
738
|
+
this.scrubberTooltip = this.shadowRoot.querySelector(".scrubber-tooltip");
|
|
739
|
+
this.markersContainer = this.shadowRoot.querySelector(".markers-container");
|
|
740
|
+
this.timeDisplay = this.shadowRoot.querySelector(".time-display");
|
|
741
|
+
this.exportBtn = this.shadowRoot.querySelector(".export-btn");
|
|
742
|
+
this.overlay = this.shadowRoot.querySelector(".status-overlay");
|
|
743
|
+
this.statusText = this.shadowRoot.querySelector(".status-text");
|
|
744
|
+
this.retryBtn = this.shadowRoot.querySelector(".retry-btn");
|
|
745
|
+
this.speedSelector = this.shadowRoot.querySelector(".speed-selector");
|
|
746
|
+
this.fullscreenBtn = this.shadowRoot.querySelector(".fullscreen-btn");
|
|
747
|
+
this.captionsContainer = this.shadowRoot.querySelector(".captions-container");
|
|
748
|
+
this.ccBtn = this.shadowRoot.querySelector(".cc-btn");
|
|
749
|
+
this.clickLayer = this.shadowRoot.querySelector(".click-layer");
|
|
750
|
+
this.posterContainer = this.shadowRoot.querySelector(".poster-container");
|
|
751
|
+
this.posterImage = this.shadowRoot.querySelector(".poster-image");
|
|
752
|
+
this.bigPlayBtn = this.shadowRoot.querySelector(".big-play-btn");
|
|
753
|
+
this.retryAction = () => this.retryConnection();
|
|
754
|
+
this.retryBtn.onclick = () => this.retryAction();
|
|
755
|
+
this.clickLayer.addEventListener("click", () => {
|
|
756
|
+
this.focus();
|
|
757
|
+
this.togglePlayPause();
|
|
758
|
+
});
|
|
759
|
+
this.clickLayer.addEventListener("dblclick", () => this.toggleFullscreen());
|
|
760
|
+
this._textTracks = new HeliosTextTrackList();
|
|
761
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
762
|
+
for (const entry of entries) {
|
|
763
|
+
const width = entry.contentRect.width;
|
|
764
|
+
const controls = this.shadowRoot.querySelector(".controls");
|
|
765
|
+
if (controls) {
|
|
766
|
+
controls.classList.toggle("layout-compact", width < 500);
|
|
767
|
+
controls.classList.toggle("layout-tiny", width < 350);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
get textTracks() {
|
|
773
|
+
return this._textTracks;
|
|
774
|
+
}
|
|
775
|
+
addTextTrack(kind, label = "", language = "") {
|
|
776
|
+
const track = new HeliosTextTrack(kind, label, language, this);
|
|
777
|
+
this._textTracks.addTrack(track);
|
|
778
|
+
return track;
|
|
779
|
+
}
|
|
780
|
+
handleTrackModeChange(track) {
|
|
781
|
+
if (!this.controller)
|
|
782
|
+
return;
|
|
783
|
+
if (track.mode === 'showing') {
|
|
784
|
+
// Enforce mutual exclusivity for 'captions'
|
|
785
|
+
if (track.kind === 'captions') {
|
|
786
|
+
for (const t of this._textTracks) {
|
|
787
|
+
if (t !== track && t.kind === 'captions' && t.mode === 'showing') {
|
|
788
|
+
t.mode = 'hidden';
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// Extract cues into the format Helios expects
|
|
793
|
+
const captions = track.cues.map((cue, index) => ({
|
|
794
|
+
id: cue.id || String(index + 1),
|
|
795
|
+
startTime: cue.startTime * 1000, // Convert seconds to milliseconds
|
|
796
|
+
endTime: cue.endTime * 1000, // Convert seconds to milliseconds
|
|
797
|
+
text: cue.text
|
|
798
|
+
}));
|
|
799
|
+
this.controller.setCaptions(captions);
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
// If hiding/disabling, check if any other track is showing
|
|
803
|
+
const showingTrack = Array.from(this._textTracks).find(t => t.mode === 'showing' && t.kind === 'captions');
|
|
804
|
+
if (showingTrack) {
|
|
805
|
+
const captions = showingTrack.cues.map((cue, index) => ({
|
|
806
|
+
id: cue.id || String(index + 1),
|
|
807
|
+
startTime: cue.startTime * 1000, // Convert seconds to milliseconds
|
|
808
|
+
endTime: cue.endTime * 1000, // Convert seconds to milliseconds
|
|
809
|
+
text: cue.text
|
|
810
|
+
}));
|
|
811
|
+
this.controller.setCaptions(captions);
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
this.controller.setCaptions([]);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
attributeChangedCallback(name, oldVal, newVal) {
|
|
819
|
+
if (oldVal === newVal)
|
|
820
|
+
return;
|
|
821
|
+
if (name === "poster") {
|
|
822
|
+
this.posterImage.src = newVal;
|
|
823
|
+
this.updatePosterVisibility();
|
|
824
|
+
}
|
|
825
|
+
if (name === "src") {
|
|
826
|
+
const preload = this.getAttribute("preload") || "auto";
|
|
827
|
+
if (preload === "none" && !this.isLoaded) {
|
|
828
|
+
this.pendingSrc = newVal;
|
|
829
|
+
this.updatePosterVisibility();
|
|
830
|
+
// Hide loading/connecting status since we are deferring load
|
|
831
|
+
this.hideStatus();
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
this.loadIframe(newVal);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (name === "width" || name === "height") {
|
|
838
|
+
this.updateAspectRatio();
|
|
839
|
+
}
|
|
840
|
+
if (name === "loop") {
|
|
841
|
+
if (this.controller) {
|
|
842
|
+
this.controller.setLoop(this.hasAttribute("loop"));
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (name === "input-props") {
|
|
846
|
+
try {
|
|
847
|
+
const props = JSON.parse(newVal);
|
|
848
|
+
this.pendingProps = props;
|
|
849
|
+
if (this.controller) {
|
|
850
|
+
this.controller.setInputProps(props);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
catch (e) {
|
|
854
|
+
console.warn("HeliosPlayer: Invalid JSON in input-props", e);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (name === "muted") {
|
|
858
|
+
if (this.controller) {
|
|
859
|
+
this.controller.setAudioMuted(this.hasAttribute("muted"));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (name === "controlslist") {
|
|
863
|
+
this.updateControlsVisibility();
|
|
864
|
+
}
|
|
865
|
+
if (name === "sandbox") {
|
|
866
|
+
const newValOrNull = this.getAttribute("sandbox");
|
|
867
|
+
// If attribute is missing (null), use default.
|
|
868
|
+
// If present (even if empty string ""), use it as is.
|
|
869
|
+
const flags = newValOrNull === null ? "allow-scripts allow-same-origin" : newValOrNull;
|
|
870
|
+
if (this.iframe.getAttribute("sandbox") !== flags) {
|
|
871
|
+
this.iframe.setAttribute("sandbox", flags);
|
|
872
|
+
// If we have a source, we must reload for new sandbox flags to apply
|
|
873
|
+
if (this.getAttribute("src")) {
|
|
874
|
+
this.loadIframe(this.getAttribute("src"));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
updateControlsVisibility() {
|
|
880
|
+
if (!this.exportBtn || !this.fullscreenBtn)
|
|
881
|
+
return;
|
|
882
|
+
const attr = this.getAttribute("controlslist") || "";
|
|
883
|
+
const tokens = attr.toLowerCase().split(/\s+/);
|
|
884
|
+
if (tokens.includes("nodownload")) {
|
|
885
|
+
this.exportBtn.style.display = "none";
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
this.exportBtn.style.removeProperty("display");
|
|
889
|
+
}
|
|
890
|
+
if (tokens.includes("nofullscreen")) {
|
|
891
|
+
this.fullscreenBtn.style.display = "none";
|
|
892
|
+
}
|
|
893
|
+
else {
|
|
894
|
+
this.fullscreenBtn.style.removeProperty("display");
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
get inputProps() {
|
|
898
|
+
return this.pendingProps;
|
|
899
|
+
}
|
|
900
|
+
set inputProps(val) {
|
|
901
|
+
this.pendingProps = val;
|
|
902
|
+
if (this.controller && val) {
|
|
903
|
+
this.controller.setInputProps(val);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
connectedCallback() {
|
|
907
|
+
this.setAttribute("tabindex", "0");
|
|
908
|
+
this.iframe.addEventListener("load", this.handleIframeLoad);
|
|
909
|
+
window.addEventListener("message", this.handleWindowMessage);
|
|
910
|
+
this.addEventListener("keydown", this.handleKeydown);
|
|
911
|
+
document.addEventListener("fullscreenchange", this.updateFullscreenUI);
|
|
912
|
+
this.playPauseBtn.addEventListener("click", this.togglePlayPause);
|
|
913
|
+
this.volumeBtn.addEventListener("click", this.toggleMute);
|
|
914
|
+
this.volumeSlider.addEventListener("input", this.handleVolumeInput);
|
|
915
|
+
this.scrubber.addEventListener("input", this.handleScrubberInput);
|
|
916
|
+
this.scrubber.addEventListener("mousedown", this.handleScrubStart);
|
|
917
|
+
this.scrubber.addEventListener("change", this.handleScrubEnd);
|
|
918
|
+
this.scrubber.addEventListener("touchstart", this.handleScrubStart, { passive: true });
|
|
919
|
+
this.scrubber.addEventListener("touchend", this.handleScrubEnd);
|
|
920
|
+
this.scrubber.addEventListener("touchcancel", this.handleScrubEnd);
|
|
921
|
+
this.scrubberWrapper.addEventListener("mousemove", this.handleScrubberHover);
|
|
922
|
+
this.scrubberWrapper.addEventListener("mouseleave", this.handleScrubberLeave);
|
|
923
|
+
this.exportBtn.addEventListener("click", this.renderClientSide);
|
|
924
|
+
this.speedSelector.addEventListener("change", this.handleSpeedChange);
|
|
925
|
+
this.fullscreenBtn.addEventListener("click", this.toggleFullscreen);
|
|
926
|
+
this.ccBtn.addEventListener("click", this.toggleCaptions);
|
|
927
|
+
this.bigPlayBtn.addEventListener("click", this.handleBigPlayClick);
|
|
928
|
+
this.posterContainer.addEventListener("click", this.handleBigPlayClick);
|
|
929
|
+
const slot = this.shadowRoot.querySelector("slot");
|
|
930
|
+
if (slot) {
|
|
931
|
+
slot.addEventListener("slotchange", this.handleSlotChange);
|
|
932
|
+
// Initial check
|
|
933
|
+
this.handleSlotChange();
|
|
934
|
+
}
|
|
935
|
+
// Initial state: disabled until connected
|
|
936
|
+
this.setControlsDisabled(true);
|
|
937
|
+
// Ensure sandbox flags are correct on connect (handling if attribute was present before upgrade)
|
|
938
|
+
const sandboxAttr = this.getAttribute("sandbox");
|
|
939
|
+
const sandboxFlags = sandboxAttr === null ? "allow-scripts allow-same-origin" : sandboxAttr;
|
|
940
|
+
if (this.iframe.getAttribute("sandbox") !== sandboxFlags) {
|
|
941
|
+
this.iframe.setAttribute("sandbox", sandboxFlags);
|
|
942
|
+
}
|
|
943
|
+
// Only show connecting if we haven't already shown "Loading..." via attributeChangedCallback
|
|
944
|
+
// AND we are not deferring load (pendingSrc is null)
|
|
945
|
+
// AND we don't have a poster (which should take precedence visually)
|
|
946
|
+
if (this.overlay.classList.contains("hidden") && !this.pendingSrc && !this.hasAttribute("poster")) {
|
|
947
|
+
this.showStatus("Connecting...", false);
|
|
948
|
+
}
|
|
949
|
+
if (this.pendingSrc) {
|
|
950
|
+
this.updatePosterVisibility();
|
|
951
|
+
}
|
|
952
|
+
// Ensure aspect ratio is correct on connect
|
|
953
|
+
this.updateAspectRatio();
|
|
954
|
+
this.updateControlsVisibility();
|
|
955
|
+
this.resizeObserver.observe(this);
|
|
956
|
+
}
|
|
957
|
+
disconnectedCallback() {
|
|
958
|
+
this.resizeObserver.disconnect();
|
|
959
|
+
this.iframe.removeEventListener("load", this.handleIframeLoad);
|
|
960
|
+
window.removeEventListener("message", this.handleWindowMessage);
|
|
961
|
+
this.removeEventListener("keydown", this.handleKeydown);
|
|
962
|
+
document.removeEventListener("fullscreenchange", this.updateFullscreenUI);
|
|
963
|
+
this.playPauseBtn.removeEventListener("click", this.togglePlayPause);
|
|
964
|
+
this.volumeBtn.removeEventListener("click", this.toggleMute);
|
|
965
|
+
this.volumeSlider.removeEventListener("input", this.handleVolumeInput);
|
|
966
|
+
this.scrubber.removeEventListener("input", this.handleScrubberInput);
|
|
967
|
+
this.scrubber.removeEventListener("mousedown", this.handleScrubStart);
|
|
968
|
+
this.scrubber.removeEventListener("change", this.handleScrubEnd);
|
|
969
|
+
this.scrubber.removeEventListener("touchstart", this.handleScrubStart);
|
|
970
|
+
this.scrubber.removeEventListener("touchend", this.handleScrubEnd);
|
|
971
|
+
this.scrubber.removeEventListener("touchcancel", this.handleScrubEnd);
|
|
972
|
+
this.scrubberWrapper.removeEventListener("mousemove", this.handleScrubberHover);
|
|
973
|
+
this.scrubberWrapper.removeEventListener("mouseleave", this.handleScrubberLeave);
|
|
974
|
+
this.exportBtn.removeEventListener("click", this.renderClientSide);
|
|
975
|
+
this.speedSelector.removeEventListener("change", this.handleSpeedChange);
|
|
976
|
+
this.fullscreenBtn.removeEventListener("click", this.toggleFullscreen);
|
|
977
|
+
this.ccBtn.removeEventListener("click", this.toggleCaptions);
|
|
978
|
+
this.bigPlayBtn.removeEventListener("click", this.handleBigPlayClick);
|
|
979
|
+
this.posterContainer.removeEventListener("click", this.handleBigPlayClick);
|
|
980
|
+
const slot = this.shadowRoot.querySelector("slot");
|
|
981
|
+
if (slot) {
|
|
982
|
+
slot.removeEventListener("slotchange", this.handleSlotChange);
|
|
983
|
+
}
|
|
984
|
+
this.stopConnectionAttempts();
|
|
985
|
+
if (this.unsubscribe) {
|
|
986
|
+
this.unsubscribe();
|
|
987
|
+
}
|
|
988
|
+
if (this.controller) {
|
|
989
|
+
this.controller.pause();
|
|
990
|
+
this.controller.dispose();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
loadIframe(src) {
|
|
994
|
+
this._error = null;
|
|
995
|
+
this._networkState = HeliosPlayer.NETWORK_LOADING;
|
|
996
|
+
this._readyState = HeliosPlayer.HAVE_NOTHING;
|
|
997
|
+
this.dispatchEvent(new Event('loadstart'));
|
|
998
|
+
this.iframe.src = src;
|
|
999
|
+
this.isLoaded = true;
|
|
1000
|
+
if (this.controller) {
|
|
1001
|
+
this.controller.pause();
|
|
1002
|
+
this.controller.dispose();
|
|
1003
|
+
this.controller = null;
|
|
1004
|
+
}
|
|
1005
|
+
this.setControlsDisabled(true);
|
|
1006
|
+
// Only show status if no poster, to avoid flashing/overlaying
|
|
1007
|
+
if (!this.hasAttribute("poster")) {
|
|
1008
|
+
this.showStatus("Loading...", false);
|
|
1009
|
+
}
|
|
1010
|
+
this.updatePosterVisibility();
|
|
1011
|
+
}
|
|
1012
|
+
handleBigPlayClick = () => {
|
|
1013
|
+
this.load();
|
|
1014
|
+
// If we are already loaded, just play
|
|
1015
|
+
if (this.controller) {
|
|
1016
|
+
this.controller.play();
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
// Set autoplay so the controller will play once connected
|
|
1020
|
+
this.setAttribute("autoplay", "");
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
updatePosterVisibility() {
|
|
1024
|
+
if (this.pendingSrc) {
|
|
1025
|
+
this.posterContainer.classList.remove("hidden");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (this.hasAttribute("poster")) {
|
|
1029
|
+
let shouldHide = false;
|
|
1030
|
+
if (this.controller) {
|
|
1031
|
+
const state = this.controller.getState();
|
|
1032
|
+
if (state.isPlaying || state.currentFrame > 0) {
|
|
1033
|
+
shouldHide = true;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
if (shouldHide) {
|
|
1037
|
+
this.posterContainer.classList.add("hidden");
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
this.posterContainer.classList.remove("hidden");
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
this.posterContainer.classList.add("hidden");
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
setControlsDisabled(disabled) {
|
|
1048
|
+
this.playPauseBtn.disabled = disabled;
|
|
1049
|
+
this.volumeBtn.disabled = disabled;
|
|
1050
|
+
this.volumeSlider.disabled = disabled;
|
|
1051
|
+
this.scrubber.disabled = disabled;
|
|
1052
|
+
this.speedSelector.disabled = disabled;
|
|
1053
|
+
this.fullscreenBtn.disabled = disabled;
|
|
1054
|
+
this.ccBtn.disabled = disabled;
|
|
1055
|
+
// Export is managed separately based on connection state
|
|
1056
|
+
if (disabled) {
|
|
1057
|
+
this.exportBtn.disabled = true;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
lockPlaybackControls(locked) {
|
|
1061
|
+
this.playPauseBtn.disabled = locked;
|
|
1062
|
+
this.volumeBtn.disabled = locked;
|
|
1063
|
+
this.volumeSlider.disabled = locked;
|
|
1064
|
+
this.scrubber.disabled = locked;
|
|
1065
|
+
this.speedSelector.disabled = locked;
|
|
1066
|
+
this.fullscreenBtn.disabled = locked;
|
|
1067
|
+
this.ccBtn.disabled = locked;
|
|
1068
|
+
}
|
|
1069
|
+
handleIframeLoad = () => {
|
|
1070
|
+
if (!this.iframe.contentWindow)
|
|
1071
|
+
return;
|
|
1072
|
+
this.startConnectionAttempts();
|
|
1073
|
+
};
|
|
1074
|
+
startConnectionAttempts() {
|
|
1075
|
+
this.stopConnectionAttempts();
|
|
1076
|
+
// 1. Bridge Mode (Fire and forget, wait for message)
|
|
1077
|
+
// We send this immediately so if the iframe is listening it can respond.
|
|
1078
|
+
this.iframe.contentWindow?.postMessage({ type: 'HELIOS_CONNECT' }, '*');
|
|
1079
|
+
// 2. Direct Mode (Polling)
|
|
1080
|
+
const checkDirect = () => {
|
|
1081
|
+
let directInstance;
|
|
1082
|
+
try {
|
|
1083
|
+
directInstance = this.iframe.contentWindow.helios;
|
|
1084
|
+
}
|
|
1085
|
+
catch (e) {
|
|
1086
|
+
// Access denied (Cross-origin)
|
|
1087
|
+
}
|
|
1088
|
+
if (directInstance) {
|
|
1089
|
+
console.log("HeliosPlayer: Connected via Direct Mode.");
|
|
1090
|
+
this.stopConnectionAttempts();
|
|
1091
|
+
this.hideStatus();
|
|
1092
|
+
this.directHelios = directInstance;
|
|
1093
|
+
this.setController(new DirectController(directInstance, this.iframe));
|
|
1094
|
+
this.exportBtn.disabled = false;
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
return false;
|
|
1098
|
+
};
|
|
1099
|
+
// Check immediately to avoid unnecessary delay
|
|
1100
|
+
if (checkDirect())
|
|
1101
|
+
return;
|
|
1102
|
+
// We poll because window.helios might be set asynchronously.
|
|
1103
|
+
const startTime = Date.now();
|
|
1104
|
+
this.connectionInterval = window.setInterval(() => {
|
|
1105
|
+
// If we connected via Bridge in the meantime, stop polling
|
|
1106
|
+
if (this.controller) {
|
|
1107
|
+
this.stopConnectionAttempts();
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (checkDirect())
|
|
1111
|
+
return;
|
|
1112
|
+
// Timeout check (5 seconds)
|
|
1113
|
+
if (Date.now() - startTime > 5000) {
|
|
1114
|
+
this.stopConnectionAttempts();
|
|
1115
|
+
if (!this.controller) {
|
|
1116
|
+
this.showStatus("Connection Failed. Ensure window.helios is set or connectToParent() is called.", true);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}, 100);
|
|
1120
|
+
}
|
|
1121
|
+
stopConnectionAttempts() {
|
|
1122
|
+
if (this.connectionInterval) {
|
|
1123
|
+
window.clearInterval(this.connectionInterval);
|
|
1124
|
+
this.connectionInterval = null;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
handleWindowMessage = (event) => {
|
|
1128
|
+
if (event.source !== this.iframe.contentWindow)
|
|
1129
|
+
return;
|
|
1130
|
+
// Check if this message is a handshake response
|
|
1131
|
+
if (event.data?.type === 'HELIOS_READY') {
|
|
1132
|
+
// If we receive a ready signal, we stop polling for direct access
|
|
1133
|
+
this.stopConnectionAttempts();
|
|
1134
|
+
this.hideStatus();
|
|
1135
|
+
// If we already have a controller (e.g. Direct Mode), we might stick with it
|
|
1136
|
+
if (!this.controller) {
|
|
1137
|
+
console.log("HeliosPlayer: Connected via Bridge Mode.");
|
|
1138
|
+
const iframeWin = this.iframe.contentWindow;
|
|
1139
|
+
if (iframeWin) {
|
|
1140
|
+
this.setController(new BridgeController(iframeWin, event.data.state));
|
|
1141
|
+
// Ensure we get the latest state immediately if provided
|
|
1142
|
+
if (event.data.state) {
|
|
1143
|
+
this.updateUI(event.data.state);
|
|
1144
|
+
}
|
|
1145
|
+
// Enable export for bridge mode
|
|
1146
|
+
this.exportBtn.disabled = false;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
handleSlotChange = () => {
|
|
1152
|
+
const slot = this.shadowRoot.querySelector("slot");
|
|
1153
|
+
if (!slot)
|
|
1154
|
+
return;
|
|
1155
|
+
const elements = slot.assignedElements();
|
|
1156
|
+
const currentTrackElements = new Set();
|
|
1157
|
+
elements.forEach((el) => {
|
|
1158
|
+
if (el.tagName === "TRACK") {
|
|
1159
|
+
const t = el;
|
|
1160
|
+
currentTrackElements.add(t);
|
|
1161
|
+
// Prevent duplicate track creation
|
|
1162
|
+
if (this._domTracks.has(t))
|
|
1163
|
+
return;
|
|
1164
|
+
const kind = t.getAttribute("kind") || "captions";
|
|
1165
|
+
const label = t.getAttribute("label") || "";
|
|
1166
|
+
const lang = t.getAttribute("srclang") || "";
|
|
1167
|
+
const src = t.getAttribute("src");
|
|
1168
|
+
const isDefault = t.hasAttribute("default");
|
|
1169
|
+
const textTrack = this.addTextTrack(kind, label, lang);
|
|
1170
|
+
this._domTracks.set(t, textTrack);
|
|
1171
|
+
if (src) {
|
|
1172
|
+
fetch(src)
|
|
1173
|
+
.then((res) => {
|
|
1174
|
+
if (!res.ok)
|
|
1175
|
+
throw new Error(`Status ${res.status}`);
|
|
1176
|
+
return res.text();
|
|
1177
|
+
})
|
|
1178
|
+
.then((srt) => {
|
|
1179
|
+
const cues = parseSRT(srt);
|
|
1180
|
+
cues.forEach(c => {
|
|
1181
|
+
textTrack.addCue(new CueClass(c.startTime, c.endTime, c.text));
|
|
1182
|
+
});
|
|
1183
|
+
if (isDefault) {
|
|
1184
|
+
textTrack.mode = 'showing';
|
|
1185
|
+
}
|
|
1186
|
+
else {
|
|
1187
|
+
textTrack.mode = 'disabled';
|
|
1188
|
+
}
|
|
1189
|
+
})
|
|
1190
|
+
.catch((err) => console.error("HeliosPlayer: Failed to load captions", err));
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
// Remove tracks that are no longer in the DOM
|
|
1195
|
+
for (const [el, track] of this._domTracks.entries()) {
|
|
1196
|
+
if (!currentTrackElements.has(el)) {
|
|
1197
|
+
if (track.mode === 'showing') {
|
|
1198
|
+
track.mode = 'hidden';
|
|
1199
|
+
}
|
|
1200
|
+
this._textTracks.removeTrack(track);
|
|
1201
|
+
this._domTracks.delete(el);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
setController(controller) {
|
|
1206
|
+
// Clean up old controller
|
|
1207
|
+
if (this.controller) {
|
|
1208
|
+
this.controller.dispose();
|
|
1209
|
+
}
|
|
1210
|
+
if (this.unsubscribe) {
|
|
1211
|
+
this.unsubscribe();
|
|
1212
|
+
this.unsubscribe = null;
|
|
1213
|
+
}
|
|
1214
|
+
this.controller = controller;
|
|
1215
|
+
// Check for pending captions
|
|
1216
|
+
this.handleSlotChange();
|
|
1217
|
+
// Update States
|
|
1218
|
+
this._networkState = HeliosPlayer.NETWORK_IDLE;
|
|
1219
|
+
this._readyState = HeliosPlayer.HAVE_ENOUGH_DATA;
|
|
1220
|
+
// Dispatch Lifecycle Events
|
|
1221
|
+
this.dispatchEvent(new Event('loadedmetadata'));
|
|
1222
|
+
this.dispatchEvent(new Event('loadeddata'));
|
|
1223
|
+
this.dispatchEvent(new Event('canplay'));
|
|
1224
|
+
this.dispatchEvent(new Event('canplaythrough'));
|
|
1225
|
+
this.setControlsDisabled(false);
|
|
1226
|
+
if (this.pendingProps) {
|
|
1227
|
+
this.controller.setInputProps(this.pendingProps);
|
|
1228
|
+
}
|
|
1229
|
+
if (this.hasAttribute("muted")) {
|
|
1230
|
+
this.controller.setAudioMuted(true);
|
|
1231
|
+
}
|
|
1232
|
+
if (this.hasAttribute("loop")) {
|
|
1233
|
+
this.controller.setLoop(true);
|
|
1234
|
+
}
|
|
1235
|
+
const state = this.controller.getState();
|
|
1236
|
+
if (state) {
|
|
1237
|
+
this.scrubber.max = String(state.duration * state.fps);
|
|
1238
|
+
this.updateUI(state);
|
|
1239
|
+
}
|
|
1240
|
+
const unsubState = this.controller.subscribe((s) => this.updateUI(s));
|
|
1241
|
+
const unsubError = this.controller.onError((err) => {
|
|
1242
|
+
const message = err.message || String(err);
|
|
1243
|
+
this._error = {
|
|
1244
|
+
code: 4, // MEDIA_ERR_SRC_NOT_SUPPORTED as generic default
|
|
1245
|
+
message: message,
|
|
1246
|
+
MEDIA_ERR_ABORTED: 1,
|
|
1247
|
+
MEDIA_ERR_NETWORK: 2,
|
|
1248
|
+
MEDIA_ERR_DECODE: 3,
|
|
1249
|
+
MEDIA_ERR_SRC_NOT_SUPPORTED: 4
|
|
1250
|
+
};
|
|
1251
|
+
this.showStatus("Error: " + message, true, {
|
|
1252
|
+
label: "Reload",
|
|
1253
|
+
handler: () => this.retryConnection()
|
|
1254
|
+
});
|
|
1255
|
+
this.dispatchEvent(new CustomEvent('error', { detail: err }));
|
|
1256
|
+
});
|
|
1257
|
+
this.unsubscribe = () => {
|
|
1258
|
+
unsubState();
|
|
1259
|
+
unsubError();
|
|
1260
|
+
};
|
|
1261
|
+
if (this.hasAttribute("autoplay")) {
|
|
1262
|
+
this.controller.play();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
updateAspectRatio() {
|
|
1266
|
+
const w = parseFloat(this.getAttribute("width") || "");
|
|
1267
|
+
const h = parseFloat(this.getAttribute("height") || "");
|
|
1268
|
+
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) {
|
|
1269
|
+
this.style.aspectRatio = `${w} / ${h}`;
|
|
1270
|
+
}
|
|
1271
|
+
else {
|
|
1272
|
+
this.style.removeProperty("aspect-ratio");
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
togglePlayPause = () => {
|
|
1276
|
+
if (!this.controller)
|
|
1277
|
+
return;
|
|
1278
|
+
const state = this.controller.getState();
|
|
1279
|
+
const isFinished = state.currentFrame >= state.duration * state.fps - 1;
|
|
1280
|
+
if (isFinished) {
|
|
1281
|
+
// Restart the animation
|
|
1282
|
+
this.controller.seek(0);
|
|
1283
|
+
this.controller.play();
|
|
1284
|
+
}
|
|
1285
|
+
else if (state.isPlaying) {
|
|
1286
|
+
this.controller.pause();
|
|
1287
|
+
}
|
|
1288
|
+
else {
|
|
1289
|
+
this.controller.play();
|
|
1290
|
+
}
|
|
1291
|
+
};
|
|
1292
|
+
toggleMute = () => {
|
|
1293
|
+
if (!this.controller)
|
|
1294
|
+
return;
|
|
1295
|
+
const state = this.controller.getState();
|
|
1296
|
+
this.controller.setAudioMuted(!state.muted);
|
|
1297
|
+
};
|
|
1298
|
+
handleVolumeInput = () => {
|
|
1299
|
+
if (!this.controller)
|
|
1300
|
+
return;
|
|
1301
|
+
const vol = parseFloat(this.volumeSlider.value);
|
|
1302
|
+
this.controller.setAudioVolume(vol);
|
|
1303
|
+
if (vol > 0) {
|
|
1304
|
+
this.controller.setAudioMuted(false);
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
handleScrubberInput = () => {
|
|
1308
|
+
const frame = parseInt(this.scrubber.value, 10);
|
|
1309
|
+
if (this.controller) {
|
|
1310
|
+
this.controller.seek(frame);
|
|
1311
|
+
}
|
|
1312
|
+
};
|
|
1313
|
+
handleScrubStart = () => {
|
|
1314
|
+
if (!this.controller)
|
|
1315
|
+
return;
|
|
1316
|
+
this.isScrubbing = true;
|
|
1317
|
+
this.dispatchEvent(new Event("seeking"));
|
|
1318
|
+
const state = this.controller.getState();
|
|
1319
|
+
this.wasPlayingBeforeScrub = state.isPlaying;
|
|
1320
|
+
if (this.wasPlayingBeforeScrub) {
|
|
1321
|
+
this.controller.pause();
|
|
1322
|
+
}
|
|
1323
|
+
};
|
|
1324
|
+
handleScrubEnd = () => {
|
|
1325
|
+
if (!this.controller)
|
|
1326
|
+
return;
|
|
1327
|
+
this.isScrubbing = false;
|
|
1328
|
+
this.dispatchEvent(new Event("seeked"));
|
|
1329
|
+
if (this.wasPlayingBeforeScrub) {
|
|
1330
|
+
this.controller.play();
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
handleScrubberHover = (e) => {
|
|
1334
|
+
if (!this.controller)
|
|
1335
|
+
return;
|
|
1336
|
+
const state = this.controller.getState();
|
|
1337
|
+
const rect = this.scrubberWrapper.getBoundingClientRect();
|
|
1338
|
+
const offsetX = e.clientX - rect.left;
|
|
1339
|
+
const width = rect.width;
|
|
1340
|
+
const pct = Math.max(0, Math.min(1, offsetX / width));
|
|
1341
|
+
const time = pct * state.duration;
|
|
1342
|
+
this.scrubberTooltip.textContent = time.toFixed(2) + "s";
|
|
1343
|
+
this.scrubberTooltip.style.left = `${offsetX}px`;
|
|
1344
|
+
this.scrubberTooltip.classList.remove("hidden");
|
|
1345
|
+
};
|
|
1346
|
+
handleScrubberLeave = () => {
|
|
1347
|
+
this.scrubberTooltip.classList.add("hidden");
|
|
1348
|
+
};
|
|
1349
|
+
handleSpeedChange = () => {
|
|
1350
|
+
if (this.controller) {
|
|
1351
|
+
this.controller.setPlaybackRate(parseFloat(this.speedSelector.value));
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
toggleCaptions = () => {
|
|
1355
|
+
this.showCaptions = !this.showCaptions;
|
|
1356
|
+
this.ccBtn.classList.toggle("active", this.showCaptions);
|
|
1357
|
+
if (this.controller) {
|
|
1358
|
+
this.updateUI(this.controller.getState());
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
handleKeydown = (e) => {
|
|
1362
|
+
if (this.isExporting)
|
|
1363
|
+
return;
|
|
1364
|
+
// Allow bubbling from children (like buttons), but ignore inputs
|
|
1365
|
+
const target = e.composedPath()[0];
|
|
1366
|
+
if (target && target.tagName) {
|
|
1367
|
+
const tagName = target.tagName.toLowerCase();
|
|
1368
|
+
if (tagName === "input" || tagName === "select" || tagName === "textarea") {
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
// If focusing a button, Space triggers click natively. Avoid double toggle.
|
|
1372
|
+
if (e.key === " " && tagName === "button") {
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (!this.controller)
|
|
1377
|
+
return;
|
|
1378
|
+
switch (e.key) {
|
|
1379
|
+
case " ":
|
|
1380
|
+
case "k":
|
|
1381
|
+
case "K":
|
|
1382
|
+
e.preventDefault(); // Prevent scrolling
|
|
1383
|
+
this.togglePlayPause();
|
|
1384
|
+
break;
|
|
1385
|
+
case "f":
|
|
1386
|
+
case "F":
|
|
1387
|
+
this.toggleFullscreen();
|
|
1388
|
+
break;
|
|
1389
|
+
case "ArrowRight":
|
|
1390
|
+
case "l":
|
|
1391
|
+
case "L":
|
|
1392
|
+
this.seekRelative(e.shiftKey ? 10 : 1);
|
|
1393
|
+
break;
|
|
1394
|
+
case "ArrowLeft":
|
|
1395
|
+
case "j":
|
|
1396
|
+
case "J":
|
|
1397
|
+
this.seekRelative(e.shiftKey ? -10 : -1);
|
|
1398
|
+
break;
|
|
1399
|
+
case "m":
|
|
1400
|
+
case "M":
|
|
1401
|
+
this.toggleMute();
|
|
1402
|
+
break;
|
|
1403
|
+
case ".":
|
|
1404
|
+
this.seekRelative(1);
|
|
1405
|
+
break;
|
|
1406
|
+
case ",":
|
|
1407
|
+
this.seekRelative(-1);
|
|
1408
|
+
break;
|
|
1409
|
+
case "i":
|
|
1410
|
+
case "I": {
|
|
1411
|
+
const s = this.controller.getState();
|
|
1412
|
+
const start = Math.floor(s.currentFrame);
|
|
1413
|
+
const totalFrames = s.duration * s.fps;
|
|
1414
|
+
let end = s.playbackRange ? s.playbackRange[1] : totalFrames;
|
|
1415
|
+
if (start >= end) {
|
|
1416
|
+
end = totalFrames;
|
|
1417
|
+
}
|
|
1418
|
+
this.controller.setPlaybackRange(start, end);
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1421
|
+
case "o":
|
|
1422
|
+
case "O": {
|
|
1423
|
+
const s = this.controller.getState();
|
|
1424
|
+
const end = Math.floor(s.currentFrame);
|
|
1425
|
+
let start = s.playbackRange ? s.playbackRange[0] : 0;
|
|
1426
|
+
if (end <= start) {
|
|
1427
|
+
start = 0;
|
|
1428
|
+
}
|
|
1429
|
+
this.controller.setPlaybackRange(start, end);
|
|
1430
|
+
break;
|
|
1431
|
+
}
|
|
1432
|
+
case "x":
|
|
1433
|
+
case "X":
|
|
1434
|
+
this.controller.clearPlaybackRange();
|
|
1435
|
+
break;
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
seekRelative(frames) {
|
|
1439
|
+
if (!this.controller)
|
|
1440
|
+
return;
|
|
1441
|
+
const state = this.controller.getState();
|
|
1442
|
+
const newFrame = Math.max(0, Math.min(Math.floor(state.duration * state.fps), state.currentFrame + frames));
|
|
1443
|
+
this.controller.seek(newFrame);
|
|
1444
|
+
}
|
|
1445
|
+
toggleFullscreen = () => {
|
|
1446
|
+
if (!document.fullscreenElement) {
|
|
1447
|
+
this.requestFullscreen().catch((err) => {
|
|
1448
|
+
console.error(`Error attempting to enable fullscreen: ${err.message}`);
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
document.exitFullscreen();
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
updateFullscreenUI = () => {
|
|
1456
|
+
if (document.fullscreenElement === this) {
|
|
1457
|
+
this.fullscreenBtn.textContent = "↙";
|
|
1458
|
+
this.fullscreenBtn.title = "Exit Fullscreen";
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
this.fullscreenBtn.textContent = "⛶";
|
|
1462
|
+
this.fullscreenBtn.title = "Fullscreen";
|
|
1463
|
+
}
|
|
1464
|
+
};
|
|
1465
|
+
updateUI(state) {
|
|
1466
|
+
// Hide poster if we are playing or have advanced
|
|
1467
|
+
if (state.isPlaying || state.currentFrame > 0) {
|
|
1468
|
+
this.posterContainer.classList.add("hidden");
|
|
1469
|
+
}
|
|
1470
|
+
// Event Dispatching
|
|
1471
|
+
if (this.lastState) {
|
|
1472
|
+
if (state.isPlaying !== this.lastState.isPlaying) {
|
|
1473
|
+
this.dispatchEvent(new Event(state.isPlaying ? "play" : "pause"));
|
|
1474
|
+
}
|
|
1475
|
+
const wasFinished = this.lastState.currentFrame >= this.lastState.duration * this.lastState.fps - 1;
|
|
1476
|
+
const isFinishedNow = state.currentFrame >= state.duration * state.fps - 1;
|
|
1477
|
+
if (!wasFinished && isFinishedNow && !state.isPlaying) {
|
|
1478
|
+
this.dispatchEvent(new Event("ended"));
|
|
1479
|
+
}
|
|
1480
|
+
if (state.currentFrame !== this.lastState.currentFrame) {
|
|
1481
|
+
this.dispatchEvent(new Event("timeupdate"));
|
|
1482
|
+
}
|
|
1483
|
+
if (state.volume !== this.lastState.volume || state.muted !== this.lastState.muted) {
|
|
1484
|
+
this.dispatchEvent(new Event("volumechange"));
|
|
1485
|
+
}
|
|
1486
|
+
if (state.playbackRate !== this.lastState.playbackRate) {
|
|
1487
|
+
this.dispatchEvent(new Event("ratechange"));
|
|
1488
|
+
}
|
|
1489
|
+
if (state.duration !== this.lastState.duration) {
|
|
1490
|
+
this.dispatchEvent(new Event("durationchange"));
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const isFinished = state.currentFrame >= state.duration * state.fps - 1;
|
|
1494
|
+
if (isFinished) {
|
|
1495
|
+
this.playPauseBtn.textContent = "🔄"; // Restart button
|
|
1496
|
+
this.playPauseBtn.setAttribute("aria-label", "Restart");
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
this.playPauseBtn.textContent = state.isPlaying ? "❚❚" : "▶";
|
|
1500
|
+
this.playPauseBtn.setAttribute("aria-label", state.isPlaying ? "Pause" : "Play");
|
|
1501
|
+
}
|
|
1502
|
+
const isMuted = state.muted || state.volume === 0;
|
|
1503
|
+
this.volumeBtn.textContent = isMuted ? "🔇" : "🔊";
|
|
1504
|
+
this.volumeBtn.setAttribute("aria-label", isMuted ? "Unmute" : "Mute");
|
|
1505
|
+
this.volumeSlider.value = String(state.volume !== undefined ? state.volume : 1);
|
|
1506
|
+
if (!this.isScrubbing) {
|
|
1507
|
+
this.scrubber.value = String(state.currentFrame);
|
|
1508
|
+
}
|
|
1509
|
+
const currentTime = (state.currentFrame / state.fps).toFixed(2);
|
|
1510
|
+
const totalTime = state.duration.toFixed(2);
|
|
1511
|
+
this.timeDisplay.textContent = `${currentTime} / ${totalTime}`;
|
|
1512
|
+
this.scrubber.setAttribute("aria-valuenow", String(state.currentFrame));
|
|
1513
|
+
this.scrubber.setAttribute("aria-valuemin", "0");
|
|
1514
|
+
this.scrubber.setAttribute("aria-valuemax", String(state.duration * state.fps));
|
|
1515
|
+
this.scrubber.setAttribute("aria-valuetext", `${currentTime} of ${totalTime} seconds`);
|
|
1516
|
+
if (state.playbackRate !== undefined) {
|
|
1517
|
+
this.speedSelector.value = String(state.playbackRate);
|
|
1518
|
+
}
|
|
1519
|
+
// Update Markers
|
|
1520
|
+
const markersChanged = !this.lastState || state.markers !== this.lastState.markers;
|
|
1521
|
+
if (markersChanged) {
|
|
1522
|
+
this.markersContainer.innerHTML = "";
|
|
1523
|
+
if (state.markers && state.duration > 0) {
|
|
1524
|
+
state.markers.forEach((marker) => {
|
|
1525
|
+
const pct = (marker.time / state.duration) * 100;
|
|
1526
|
+
if (pct >= 0 && pct <= 100) {
|
|
1527
|
+
const el = document.createElement("div");
|
|
1528
|
+
el.className = "marker";
|
|
1529
|
+
el.style.left = `${pct}%`;
|
|
1530
|
+
if (marker.color)
|
|
1531
|
+
el.style.backgroundColor = marker.color;
|
|
1532
|
+
el.title = marker.label || "";
|
|
1533
|
+
el.addEventListener("click", (e) => {
|
|
1534
|
+
e.stopPropagation();
|
|
1535
|
+
if (this.controller) {
|
|
1536
|
+
this.controller.seek(Math.floor(marker.time * state.fps));
|
|
1537
|
+
}
|
|
1538
|
+
});
|
|
1539
|
+
this.markersContainer.appendChild(el);
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (state.playbackRange) {
|
|
1545
|
+
const totalFrames = state.duration * state.fps;
|
|
1546
|
+
if (totalFrames > 0) {
|
|
1547
|
+
const [start, end] = state.playbackRange;
|
|
1548
|
+
const startPct = (start / totalFrames) * 100;
|
|
1549
|
+
const endPct = (end / totalFrames) * 100;
|
|
1550
|
+
this.scrubber.style.background = `linear-gradient(to right,
|
|
1551
|
+
var(--helios-range-unselected-color) 0%,
|
|
1552
|
+
var(--helios-range-unselected-color) ${startPct}%,
|
|
1553
|
+
var(--helios-range-selected-color) ${startPct}%,
|
|
1554
|
+
var(--helios-range-selected-color) ${endPct}%,
|
|
1555
|
+
var(--helios-range-unselected-color) ${endPct}%,
|
|
1556
|
+
var(--helios-range-unselected-color) 100%
|
|
1557
|
+
)`;
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
this.scrubber.style.background = '';
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
else {
|
|
1564
|
+
this.scrubber.style.background = '';
|
|
1565
|
+
}
|
|
1566
|
+
this.captionsContainer.innerHTML = '';
|
|
1567
|
+
if (this.showCaptions && state.activeCaptions && state.activeCaptions.length > 0) {
|
|
1568
|
+
state.activeCaptions.forEach((cue) => {
|
|
1569
|
+
const div = document.createElement('div');
|
|
1570
|
+
div.className = 'caption-cue';
|
|
1571
|
+
div.textContent = cue.text;
|
|
1572
|
+
this.captionsContainer.appendChild(div);
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
this.lastState = state;
|
|
1576
|
+
}
|
|
1577
|
+
// --- Loading / Error UI Helpers ---
|
|
1578
|
+
showStatus(msg, isError, action) {
|
|
1579
|
+
this.overlay.classList.remove("hidden");
|
|
1580
|
+
this.statusText.textContent = msg;
|
|
1581
|
+
this.retryBtn.style.display = isError ? "block" : "none";
|
|
1582
|
+
if (action) {
|
|
1583
|
+
this.retryBtn.textContent = action.label;
|
|
1584
|
+
this.retryAction = action.handler;
|
|
1585
|
+
}
|
|
1586
|
+
else {
|
|
1587
|
+
this.retryBtn.textContent = "Retry";
|
|
1588
|
+
this.retryAction = () => this.retryConnection();
|
|
1589
|
+
}
|
|
1590
|
+
// Optional: Add visual distinction for errors beyond just the button
|
|
1591
|
+
this.statusText.classList.toggle('error-msg', isError);
|
|
1592
|
+
}
|
|
1593
|
+
hideStatus() {
|
|
1594
|
+
this.overlay.classList.add("hidden");
|
|
1595
|
+
}
|
|
1596
|
+
getController() {
|
|
1597
|
+
return this.controller;
|
|
1598
|
+
}
|
|
1599
|
+
async getSchema() {
|
|
1600
|
+
if (this.controller) {
|
|
1601
|
+
return this.controller.getSchema();
|
|
1602
|
+
}
|
|
1603
|
+
return undefined;
|
|
1604
|
+
}
|
|
1605
|
+
retryConnection() {
|
|
1606
|
+
this.showStatus("Retrying...", false);
|
|
1607
|
+
// Reload iframe to force fresh start
|
|
1608
|
+
this.load();
|
|
1609
|
+
}
|
|
1610
|
+
renderClientSide = async () => {
|
|
1611
|
+
// If we are already exporting, this is a cancel request
|
|
1612
|
+
if (this.abortController) {
|
|
1613
|
+
this.abortController.abort();
|
|
1614
|
+
this.abortController = null;
|
|
1615
|
+
this.exportBtn.textContent = "Export";
|
|
1616
|
+
this.exportBtn.disabled = false;
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
// Export requires Controller (Direct or Bridge)
|
|
1620
|
+
if (!this.controller) {
|
|
1621
|
+
console.error("Export not available: Not connected.");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
this.abortController = new AbortController();
|
|
1625
|
+
this.exportBtn.textContent = "Cancel";
|
|
1626
|
+
this.isExporting = true;
|
|
1627
|
+
this.lockPlaybackControls(true);
|
|
1628
|
+
const exporter = new ClientSideExporter(this.controller, this.iframe);
|
|
1629
|
+
const exportMode = (this.getAttribute("export-mode") || "auto");
|
|
1630
|
+
const canvasSelector = this.getAttribute("canvas-selector") || "canvas";
|
|
1631
|
+
const exportFormat = (this.getAttribute("export-format") || "mp4");
|
|
1632
|
+
const captionMode = (this.getAttribute("export-caption-mode") || "burn-in");
|
|
1633
|
+
let includeCaptions = this.showCaptions;
|
|
1634
|
+
if (this.showCaptions && captionMode === 'file') {
|
|
1635
|
+
const showingTrack = Array.from(this._textTracks).find(t => t.mode === 'showing' && t.kind === 'captions');
|
|
1636
|
+
if (showingTrack) {
|
|
1637
|
+
// Convert TextTrackCueList to Array before mapping
|
|
1638
|
+
const cues = Array.from(showingTrack.cues).map((cue) => ({
|
|
1639
|
+
startTime: cue.startTime,
|
|
1640
|
+
endTime: cue.endTime,
|
|
1641
|
+
text: cue.text
|
|
1642
|
+
}));
|
|
1643
|
+
exporter.saveCaptionsAsSRT(cues, "captions.srt");
|
|
1644
|
+
}
|
|
1645
|
+
includeCaptions = false;
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
await exporter.export({
|
|
1649
|
+
onProgress: (p) => {
|
|
1650
|
+
this.exportBtn.textContent = `Cancel (${Math.round(p * 100)}%)`;
|
|
1651
|
+
},
|
|
1652
|
+
signal: this.abortController.signal,
|
|
1653
|
+
mode: exportMode,
|
|
1654
|
+
canvasSelector: canvasSelector,
|
|
1655
|
+
format: exportFormat,
|
|
1656
|
+
includeCaptions: includeCaptions
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
catch (e) {
|
|
1660
|
+
if (e.message !== "Export aborted") {
|
|
1661
|
+
this.showStatus("Export Failed: " + e.message, true, {
|
|
1662
|
+
label: "Dismiss",
|
|
1663
|
+
handler: () => this.hideStatus()
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
console.error("Export failed or aborted", e);
|
|
1667
|
+
}
|
|
1668
|
+
finally {
|
|
1669
|
+
this.isExporting = false;
|
|
1670
|
+
this.lockPlaybackControls(false);
|
|
1671
|
+
this.exportBtn.textContent = "Export";
|
|
1672
|
+
this.exportBtn.disabled = false;
|
|
1673
|
+
this.abortController = null;
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
if (!customElements.get("helios-player")) {
|
|
1678
|
+
customElements.define("helios-player", HeliosPlayer);
|
|
1679
|
+
}
|