vidply 1.0.28 → 1.0.30
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/dist/dev/vidply.HLSRenderer-UMPUDSYL.js +266 -0
- package/dist/dev/vidply.HLSRenderer-UMPUDSYL.js.map +7 -0
- package/dist/dev/vidply.HTML5Renderer-FXBZQL6Y.js +12 -0
- package/dist/dev/vidply.HTML5Renderer-FXBZQL6Y.js.map +7 -0
- package/dist/dev/{vidply.TranscriptManager-QSF2PWUN.js → vidply.TranscriptManager-T677KF4N.js} +4 -5
- package/dist/dev/{vidply.TranscriptManager-QSF2PWUN.js.map → vidply.TranscriptManager-T677KF4N.js.map} +2 -2
- package/dist/dev/{vidply.chunk-SRM7VNHG.js → vidply.chunk-GS2JX5RQ.js} +136 -95
- package/dist/dev/vidply.chunk-GS2JX5RQ.js.map +7 -0
- package/dist/dev/vidply.chunk-W2LSBD6Y.js +251 -0
- package/dist/dev/vidply.chunk-W2LSBD6Y.js.map +7 -0
- package/dist/dev/vidply.esm.js +1880 -258
- package/dist/dev/vidply.esm.js.map +4 -4
- package/dist/legacy/vidply.js +2056 -365
- package/dist/legacy/vidply.js.map +4 -4
- package/dist/legacy/vidply.min.js +1 -1
- package/dist/legacy/vidply.min.meta.json +111 -25
- package/dist/prod/vidply.HLSRenderer-3CG7BZKA.min.js +6 -0
- package/dist/prod/vidply.HTML5Renderer-KKW3OLHM.min.js +6 -0
- package/dist/prod/vidply.TranscriptManager-WFZSW6NR.min.js +6 -0
- package/dist/prod/vidply.chunk-34RH2THY.min.js +6 -0
- package/dist/prod/vidply.chunk-LGTJRPUL.min.js +6 -0
- package/dist/prod/vidply.esm.min.js +8 -8
- package/dist/vidply.css +20 -1
- package/dist/vidply.esm.min.meta.json +120 -34
- package/dist/vidply.min.css +1 -1
- package/package.json +2 -2
- package/src/controls/ControlBar.js +182 -10
- package/src/controls/TranscriptManager.js +7 -7
- package/src/core/AudioDescriptionManager.js +701 -0
- package/src/core/Player.js +203 -256
- package/src/core/SignLanguageManager.js +1134 -0
- package/src/renderers/HTML5Renderer.js +7 -0
- package/src/styles/vidply.css +20 -1
- package/src/utils/DOMUtils.js +153 -114
- package/src/utils/MenuFactory.js +374 -0
- package/src/utils/VideoFrameCapture.js +110 -0
- package/dist/dev/vidply.TranscriptManager-GZKY44ON.js +0 -1744
- package/dist/dev/vidply.TranscriptManager-GZKY44ON.js.map +0 -7
- package/dist/dev/vidply.TranscriptManager-UTJBQC5B.js +0 -1744
- package/dist/dev/vidply.TranscriptManager-UTJBQC5B.js.map +0 -7
- package/dist/dev/vidply.chunk-5663PYKK.js +0 -1631
- package/dist/dev/vidply.chunk-5663PYKK.js.map +0 -7
- package/dist/dev/vidply.chunk-SRM7VNHG.js.map +0 -7
- package/dist/dev/vidply.chunk-UH5MTGKF.js +0 -1630
- package/dist/dev/vidply.chunk-UH5MTGKF.js.map +0 -7
- package/dist/dev/vidply.de-RXAJM5QE.js +0 -181
- package/dist/dev/vidply.de-RXAJM5QE.js.map +0 -7
- package/dist/dev/vidply.de-THBIMP4S.js +0 -180
- package/dist/dev/vidply.de-THBIMP4S.js.map +0 -7
- package/dist/dev/vidply.es-6VWDNNNL.js +0 -180
- package/dist/dev/vidply.es-6VWDNNNL.js.map +0 -7
- package/dist/dev/vidply.es-SADVLJTQ.js +0 -181
- package/dist/dev/vidply.es-SADVLJTQ.js.map +0 -7
- package/dist/dev/vidply.fr-V3VAYBBT.js +0 -181
- package/dist/dev/vidply.fr-V3VAYBBT.js.map +0 -7
- package/dist/dev/vidply.fr-WHTWCHWT.js +0 -180
- package/dist/dev/vidply.fr-WHTWCHWT.js.map +0 -7
- package/dist/dev/vidply.ja-BFQNPOFI.js +0 -180
- package/dist/dev/vidply.ja-BFQNPOFI.js.map +0 -7
- package/dist/dev/vidply.ja-KL2TLZGJ.js +0 -181
- package/dist/dev/vidply.ja-KL2TLZGJ.js.map +0 -7
- package/dist/prod/vidply.TranscriptManager-DZ2WZU3K.min.js +0 -6
- package/dist/prod/vidply.TranscriptManager-E5QHGFIR.min.js +0 -6
- package/dist/prod/vidply.TranscriptManager-UZ6DUFB6.min.js +0 -6
- package/dist/prod/vidply.chunk-5DWTMWEO.min.js +0 -6
- package/dist/prod/vidply.chunk-IBNYTGGM.min.js +0 -6
- package/dist/prod/vidply.chunk-MBUR3U5L.min.js +0 -6
- package/dist/prod/vidply.de-HGJBCLLE.min.js +0 -6
- package/dist/prod/vidply.de-SWFW4HYT.min.js +0 -6
- package/dist/prod/vidply.es-7BJ2DJAY.min.js +0 -6
- package/dist/prod/vidply.es-CZEBXCZN.min.js +0 -6
- package/dist/prod/vidply.fr-DPVR5DFY.min.js +0 -6
- package/dist/prod/vidply.fr-HFOL7MWA.min.js +0 -6
- package/dist/prod/vidply.ja-PEBVWKVH.min.js +0 -6
- package/dist/prod/vidply.ja-QTVU5C25.min.js +0 -6
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu Factory - Centralized menu creation and management
|
|
3
|
+
* Reduces code duplication across ControlBar menu methods
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { DOMUtils } from './DOMUtils.js';
|
|
7
|
+
import { createIconElement } from '../icons/Icons.js';
|
|
8
|
+
import { i18n } from '../i18n/i18n.js';
|
|
9
|
+
import { attachMenuKeyboardNavigation, focusFirstMenuItem } from './MenuUtils.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create and show a menu with standard positioning and behavior
|
|
13
|
+
* @param {Object} options - Menu configuration
|
|
14
|
+
* @returns {HTMLElement} The created menu element
|
|
15
|
+
*/
|
|
16
|
+
export function createMenu({
|
|
17
|
+
player,
|
|
18
|
+
button,
|
|
19
|
+
menuClass,
|
|
20
|
+
ariaLabel,
|
|
21
|
+
items = [],
|
|
22
|
+
activeIndex = -1,
|
|
23
|
+
onClose = null,
|
|
24
|
+
insertIntoDOM = null,
|
|
25
|
+
positionMenu = null,
|
|
26
|
+
attachCloseHandler = null
|
|
27
|
+
}) {
|
|
28
|
+
const classPrefix = player.options.classPrefix;
|
|
29
|
+
|
|
30
|
+
// Remove existing menu (toggle behavior)
|
|
31
|
+
const existingMenu = document.querySelector(`.${classPrefix}-${menuClass}`);
|
|
32
|
+
if (existingMenu) {
|
|
33
|
+
existingMenu.remove();
|
|
34
|
+
button.setAttribute('aria-expanded', 'false');
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Create menu container
|
|
39
|
+
const menu = DOMUtils.createElement('div', {
|
|
40
|
+
className: `${classPrefix}-${menuClass} ${classPrefix}-menu`,
|
|
41
|
+
attributes: {
|
|
42
|
+
'role': 'menu',
|
|
43
|
+
'aria-label': ariaLabel
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let activeItem = null;
|
|
48
|
+
|
|
49
|
+
// Create menu items
|
|
50
|
+
items.forEach((itemConfig, index) => {
|
|
51
|
+
if (itemConfig.type === 'divider') {
|
|
52
|
+
const divider = DOMUtils.createElement('div', {
|
|
53
|
+
className: `${classPrefix}-menu-divider`,
|
|
54
|
+
attributes: { 'role': 'separator' }
|
|
55
|
+
});
|
|
56
|
+
menu.appendChild(divider);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (itemConfig.type === 'header') {
|
|
61
|
+
const header = DOMUtils.createElement('div', {
|
|
62
|
+
className: `${classPrefix}-menu-header`,
|
|
63
|
+
textContent: itemConfig.text
|
|
64
|
+
});
|
|
65
|
+
menu.appendChild(header);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (itemConfig.disabled) {
|
|
70
|
+
const disabledItem = DOMUtils.createElement('div', {
|
|
71
|
+
className: `${classPrefix}-menu-item`,
|
|
72
|
+
textContent: itemConfig.text,
|
|
73
|
+
attributes: { 'role': 'menuitem' },
|
|
74
|
+
style: { opacity: '0.5', cursor: 'default' }
|
|
75
|
+
});
|
|
76
|
+
menu.appendChild(disabledItem);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const item = DOMUtils.createElement('button', {
|
|
81
|
+
className: `${classPrefix}-menu-item`,
|
|
82
|
+
attributes: {
|
|
83
|
+
'type': 'button',
|
|
84
|
+
'role': 'menuitem',
|
|
85
|
+
'tabindex': '-1'
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Add content based on item type
|
|
90
|
+
if (itemConfig.icon) {
|
|
91
|
+
item.appendChild(createIconElement(itemConfig.icon));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (itemConfig.timeLabel) {
|
|
95
|
+
const timeSpan = DOMUtils.createElement('span', {
|
|
96
|
+
className: `${classPrefix}-chapter-time`,
|
|
97
|
+
textContent: itemConfig.timeLabel,
|
|
98
|
+
attributes: itemConfig.timeAriaLabel
|
|
99
|
+
? { 'aria-label': itemConfig.timeAriaLabel }
|
|
100
|
+
: {}
|
|
101
|
+
});
|
|
102
|
+
item.appendChild(timeSpan);
|
|
103
|
+
item.appendChild(document.createTextNode(' '));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (itemConfig.text) {
|
|
107
|
+
const textSpan = DOMUtils.createElement('span', {
|
|
108
|
+
className: itemConfig.textClass || `${classPrefix}-menu-item-text`,
|
|
109
|
+
textContent: itemConfig.text
|
|
110
|
+
});
|
|
111
|
+
item.appendChild(textSpan);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Mark as active
|
|
115
|
+
const isActive = itemConfig.active || index === activeIndex;
|
|
116
|
+
if (isActive) {
|
|
117
|
+
item.classList.add(`${classPrefix}-menu-item-active`);
|
|
118
|
+
item.appendChild(createIconElement('check'));
|
|
119
|
+
activeItem = item;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Click handler
|
|
123
|
+
if (itemConfig.onClick) {
|
|
124
|
+
item.addEventListener('click', () => {
|
|
125
|
+
itemConfig.onClick(itemConfig.value, index);
|
|
126
|
+
closeMenuAndReturnFocus(menu, button, onClose);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
menu.appendChild(item);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Position menu (hide first to prevent jumping)
|
|
134
|
+
menu.style.visibility = 'hidden';
|
|
135
|
+
menu.style.display = 'block';
|
|
136
|
+
|
|
137
|
+
// Insert into DOM
|
|
138
|
+
if (insertIntoDOM) {
|
|
139
|
+
insertIntoDOM(menu, button);
|
|
140
|
+
} else {
|
|
141
|
+
button.insertAdjacentElement('afterend', menu);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Position
|
|
145
|
+
if (positionMenu) {
|
|
146
|
+
positionMenu(menu, button, true);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Show menu
|
|
150
|
+
requestAnimationFrame(() => {
|
|
151
|
+
menu.style.visibility = 'visible';
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Add keyboard navigation
|
|
155
|
+
attachMenuKeyboardNavigation(menu, button, `.${classPrefix}-menu-item`, () => {
|
|
156
|
+
closeMenuAndReturnFocus(menu, button, onClose);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Focus active or first item
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
const focusTarget = activeItem || menu.querySelector(`.${classPrefix}-menu-item`);
|
|
162
|
+
if (focusTarget) {
|
|
163
|
+
focusTarget.focus({ preventScroll: true });
|
|
164
|
+
}
|
|
165
|
+
}, 0);
|
|
166
|
+
|
|
167
|
+
// Attach close handler
|
|
168
|
+
if (attachCloseHandler) {
|
|
169
|
+
attachCloseHandler(menu, button);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
button.setAttribute('aria-expanded', 'true');
|
|
173
|
+
|
|
174
|
+
return menu;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Close menu and return focus to button
|
|
179
|
+
*/
|
|
180
|
+
function closeMenuAndReturnFocus(menu, button, onClose) {
|
|
181
|
+
if (menu) {
|
|
182
|
+
menu.remove();
|
|
183
|
+
}
|
|
184
|
+
button.setAttribute('aria-expanded', 'false');
|
|
185
|
+
button.focus({ preventScroll: true });
|
|
186
|
+
if (onClose) {
|
|
187
|
+
onClose();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Create a speed menu
|
|
193
|
+
*/
|
|
194
|
+
export function createSpeedMenu({
|
|
195
|
+
player,
|
|
196
|
+
button,
|
|
197
|
+
currentSpeed,
|
|
198
|
+
speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
|
199
|
+
onSpeedChange,
|
|
200
|
+
insertIntoDOM,
|
|
201
|
+
positionMenu,
|
|
202
|
+
attachCloseHandler
|
|
203
|
+
}) {
|
|
204
|
+
const items = speeds.map(speed => ({
|
|
205
|
+
text: speed === 1 ? i18n.t('player.normalSpeed') : `${speed}x`,
|
|
206
|
+
value: speed,
|
|
207
|
+
active: Math.abs(currentSpeed - speed) < 0.01,
|
|
208
|
+
onClick: (value) => onSpeedChange(value)
|
|
209
|
+
}));
|
|
210
|
+
|
|
211
|
+
return createMenu({
|
|
212
|
+
player,
|
|
213
|
+
button,
|
|
214
|
+
menuClass: 'speed-menu',
|
|
215
|
+
ariaLabel: i18n.t('player.speed'),
|
|
216
|
+
items,
|
|
217
|
+
insertIntoDOM,
|
|
218
|
+
positionMenu,
|
|
219
|
+
attachCloseHandler
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Create a captions menu
|
|
225
|
+
*/
|
|
226
|
+
export function createCaptionsMenu({
|
|
227
|
+
player,
|
|
228
|
+
button,
|
|
229
|
+
tracks,
|
|
230
|
+
currentTrackIndex,
|
|
231
|
+
captionsEnabled,
|
|
232
|
+
onTrackSelect,
|
|
233
|
+
onDisable,
|
|
234
|
+
insertIntoDOM,
|
|
235
|
+
positionMenu,
|
|
236
|
+
attachCloseHandler
|
|
237
|
+
}) {
|
|
238
|
+
const classPrefix = player.options.classPrefix;
|
|
239
|
+
|
|
240
|
+
const items = [
|
|
241
|
+
{
|
|
242
|
+
text: i18n.t('player.captionsOff'),
|
|
243
|
+
active: !captionsEnabled,
|
|
244
|
+
onClick: () => onDisable()
|
|
245
|
+
}
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
tracks.forEach((track, index) => {
|
|
249
|
+
items.push({
|
|
250
|
+
text: track.label || track.language,
|
|
251
|
+
value: index,
|
|
252
|
+
active: captionsEnabled && currentTrackIndex === index,
|
|
253
|
+
onClick: () => onTrackSelect(index)
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return createMenu({
|
|
258
|
+
player,
|
|
259
|
+
button,
|
|
260
|
+
menuClass: 'captions-menu',
|
|
261
|
+
ariaLabel: i18n.t('player.captions'),
|
|
262
|
+
items,
|
|
263
|
+
insertIntoDOM,
|
|
264
|
+
positionMenu,
|
|
265
|
+
attachCloseHandler
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Create a chapters menu
|
|
271
|
+
*/
|
|
272
|
+
export function createChaptersMenu({
|
|
273
|
+
player,
|
|
274
|
+
button,
|
|
275
|
+
chapters,
|
|
276
|
+
onChapterSelect,
|
|
277
|
+
formatTime,
|
|
278
|
+
formatDuration,
|
|
279
|
+
insertIntoDOM,
|
|
280
|
+
positionMenu,
|
|
281
|
+
attachCloseHandler
|
|
282
|
+
}) {
|
|
283
|
+
if (!chapters || chapters.length === 0) {
|
|
284
|
+
return createMenu({
|
|
285
|
+
player,
|
|
286
|
+
button,
|
|
287
|
+
menuClass: 'chapters-menu',
|
|
288
|
+
ariaLabel: i18n.t('player.chapters'),
|
|
289
|
+
items: [{
|
|
290
|
+
text: i18n.t('player.noChapters'),
|
|
291
|
+
disabled: true
|
|
292
|
+
}],
|
|
293
|
+
insertIntoDOM,
|
|
294
|
+
positionMenu,
|
|
295
|
+
attachCloseHandler
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const items = chapters.map(chapter => ({
|
|
300
|
+
timeLabel: formatTime(chapter.startTime),
|
|
301
|
+
timeAriaLabel: formatDuration(chapter.startTime),
|
|
302
|
+
text: chapter.text,
|
|
303
|
+
textClass: `${player.options.classPrefix}-chapter-title`,
|
|
304
|
+
value: chapter.startTime,
|
|
305
|
+
onClick: (value) => onChapterSelect(value)
|
|
306
|
+
}));
|
|
307
|
+
|
|
308
|
+
return createMenu({
|
|
309
|
+
player,
|
|
310
|
+
button,
|
|
311
|
+
menuClass: 'chapters-menu',
|
|
312
|
+
ariaLabel: i18n.t('player.chapters'),
|
|
313
|
+
items,
|
|
314
|
+
insertIntoDOM,
|
|
315
|
+
positionMenu,
|
|
316
|
+
attachCloseHandler
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Create a quality menu
|
|
322
|
+
*/
|
|
323
|
+
export function createQualityMenu({
|
|
324
|
+
player,
|
|
325
|
+
button,
|
|
326
|
+
qualities,
|
|
327
|
+
currentQuality,
|
|
328
|
+
isHLS,
|
|
329
|
+
onQualitySelect,
|
|
330
|
+
insertIntoDOM,
|
|
331
|
+
positionMenu,
|
|
332
|
+
attachCloseHandler
|
|
333
|
+
}) {
|
|
334
|
+
const items = [];
|
|
335
|
+
|
|
336
|
+
// Auto option for HLS
|
|
337
|
+
if (isHLS) {
|
|
338
|
+
items.push({
|
|
339
|
+
text: i18n.t('player.auto'),
|
|
340
|
+
value: -1,
|
|
341
|
+
active: currentQuality === -1,
|
|
342
|
+
onClick: () => onQualitySelect(-1)
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Quality options
|
|
347
|
+
qualities.forEach(quality => {
|
|
348
|
+
items.push({
|
|
349
|
+
text: quality.name || `${quality.height}p`,
|
|
350
|
+
value: quality.index,
|
|
351
|
+
active: quality.index === currentQuality,
|
|
352
|
+
onClick: () => onQualitySelect(quality.index)
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
if (items.length === 0) {
|
|
357
|
+
items.push({
|
|
358
|
+
text: i18n.t('player.autoQuality'),
|
|
359
|
+
disabled: true
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return createMenu({
|
|
364
|
+
player,
|
|
365
|
+
button,
|
|
366
|
+
menuClass: 'quality-menu',
|
|
367
|
+
ariaLabel: i18n.t('player.quality'),
|
|
368
|
+
items,
|
|
369
|
+
insertIntoDOM,
|
|
370
|
+
positionMenu,
|
|
371
|
+
attachCloseHandler
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility for capturing frames from video elements
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capture a frame from a video element at a specific time
|
|
7
|
+
* @param {HTMLVideoElement} video - Video element to capture from
|
|
8
|
+
* @param {number} time - Time in seconds to capture
|
|
9
|
+
* @param {Object} options - Options for frame capture
|
|
10
|
+
* @param {boolean} [options.restoreState=true] - Whether to restore video state after capture
|
|
11
|
+
* @param {number} [options.quality=0.9] - JPEG quality (0-1)
|
|
12
|
+
* @param {number} [options.maxWidth] - Maximum width for thumbnail
|
|
13
|
+
* @param {number} [options.maxHeight] - Maximum height for thumbnail
|
|
14
|
+
* @returns {Promise<string|null>} Data URL of the captured frame or null if failed
|
|
15
|
+
*/
|
|
16
|
+
export async function captureVideoFrame(video, time, options = {}) {
|
|
17
|
+
if (!video || video.tagName !== 'VIDEO') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const {
|
|
22
|
+
restoreState = true,
|
|
23
|
+
quality = 0.9,
|
|
24
|
+
maxWidth,
|
|
25
|
+
maxHeight
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
// Save original state if we need to restore it
|
|
29
|
+
const wasPlaying = !video.paused;
|
|
30
|
+
const originalTime = video.currentTime;
|
|
31
|
+
const originalMuted = video.muted;
|
|
32
|
+
|
|
33
|
+
// Ensure video is muted during capture to avoid audio playback
|
|
34
|
+
if (restoreState) {
|
|
35
|
+
video.muted = true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const captureFrame = () => {
|
|
40
|
+
try {
|
|
41
|
+
// Get video dimensions
|
|
42
|
+
let width = video.videoWidth || 640;
|
|
43
|
+
let height = video.videoHeight || 360;
|
|
44
|
+
|
|
45
|
+
// Scale down if max dimensions specified
|
|
46
|
+
if (maxWidth && width > maxWidth) {
|
|
47
|
+
const ratio = maxWidth / width;
|
|
48
|
+
width = maxWidth;
|
|
49
|
+
height = Math.round(height * ratio);
|
|
50
|
+
}
|
|
51
|
+
if (maxHeight && height > maxHeight) {
|
|
52
|
+
const ratio = maxHeight / height;
|
|
53
|
+
height = maxHeight;
|
|
54
|
+
width = Math.round(width * ratio);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create canvas to capture frame
|
|
58
|
+
const canvas = document.createElement('canvas');
|
|
59
|
+
canvas.width = width;
|
|
60
|
+
canvas.height = height;
|
|
61
|
+
|
|
62
|
+
const ctx = canvas.getContext('2d');
|
|
63
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
64
|
+
|
|
65
|
+
const dataURL = canvas.toDataURL('image/jpeg', quality);
|
|
66
|
+
|
|
67
|
+
// Restore original state if needed
|
|
68
|
+
if (restoreState) {
|
|
69
|
+
video.currentTime = originalTime;
|
|
70
|
+
video.muted = originalMuted;
|
|
71
|
+
if (wasPlaying && !video.paused) {
|
|
72
|
+
video.play().catch(() => {});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
resolve(dataURL);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
// Restore original state on error
|
|
79
|
+
if (restoreState) {
|
|
80
|
+
video.currentTime = originalTime;
|
|
81
|
+
video.muted = originalMuted;
|
|
82
|
+
if (wasPlaying && !video.paused) {
|
|
83
|
+
video.play().catch(() => {});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
resolve(null);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const onSeeked = () => {
|
|
91
|
+
video.removeEventListener('seeked', onSeeked);
|
|
92
|
+
// Wait for frame to be ready (double RAF for better frame quality)
|
|
93
|
+
requestAnimationFrame(() => {
|
|
94
|
+
requestAnimationFrame(captureFrame);
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Check if video is already at the right time and ready
|
|
99
|
+
const timeDiff = Math.abs(video.currentTime - time);
|
|
100
|
+
if (timeDiff < 0.1 && video.readyState >= 2) {
|
|
101
|
+
// Video is already at the right position, capture immediately
|
|
102
|
+
captureFrame();
|
|
103
|
+
} else {
|
|
104
|
+
// Seek to the desired time
|
|
105
|
+
video.addEventListener('seeked', onSeeked);
|
|
106
|
+
video.currentTime = time;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|