vidply 1.0.22 → 1.0.25

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 (66) hide show
  1. package/dist/dev/vidply.HLSRenderer-PNP5OPES.js +255 -0
  2. package/dist/dev/vidply.HLSRenderer-PNP5OPES.js.map +7 -0
  3. package/dist/dev/vidply.HTML5Renderer-LXQ3I45Q.js +12 -0
  4. package/dist/dev/vidply.HTML5Renderer-LXQ3I45Q.js.map +7 -0
  5. package/dist/dev/vidply.TranscriptManager-GZKY44ON.js +1744 -0
  6. package/dist/dev/vidply.TranscriptManager-GZKY44ON.js.map +7 -0
  7. package/dist/dev/vidply.VimeoRenderer-DCETT5IZ.js +213 -0
  8. package/dist/dev/vidply.VimeoRenderer-DCETT5IZ.js.map +7 -0
  9. package/dist/dev/vidply.YouTubeRenderer-QLMMD757.js +227 -0
  10. package/dist/dev/vidply.YouTubeRenderer-QLMMD757.js.map +7 -0
  11. package/dist/dev/vidply.chunk-UEIJOJH6.js +243 -0
  12. package/dist/dev/vidply.chunk-UEIJOJH6.js.map +7 -0
  13. package/dist/dev/vidply.chunk-UH5MTGKF.js +1630 -0
  14. package/dist/dev/vidply.chunk-UH5MTGKF.js.map +7 -0
  15. package/dist/dev/vidply.de-THBIMP4S.js +180 -0
  16. package/dist/dev/vidply.de-THBIMP4S.js.map +7 -0
  17. package/dist/dev/vidply.es-6VWDNNNL.js +180 -0
  18. package/dist/dev/vidply.es-6VWDNNNL.js.map +7 -0
  19. package/dist/{vidply.esm.js → dev/vidply.esm.js} +530 -5082
  20. package/dist/dev/vidply.esm.js.map +7 -0
  21. package/dist/dev/vidply.fr-WHTWCHWT.js +180 -0
  22. package/dist/dev/vidply.fr-WHTWCHWT.js.map +7 -0
  23. package/dist/dev/vidply.ja-BFQNPOFI.js +180 -0
  24. package/dist/dev/vidply.ja-BFQNPOFI.js.map +7 -0
  25. package/dist/{vidply.js → legacy/vidply.js} +7833 -7317
  26. package/dist/legacy/vidply.js.map +7 -0
  27. package/dist/legacy/vidply.min.js +6 -0
  28. package/dist/{vidply.min.meta.json → legacy/vidply.min.meta.json} +120 -94
  29. package/dist/prod/vidply.HLSRenderer-4PW35TCX.min.js +6 -0
  30. package/dist/prod/vidply.HTML5Renderer-XJCSUETP.min.js +6 -0
  31. package/dist/prod/vidply.TranscriptManager-UZ6DUFB6.min.js +6 -0
  32. package/dist/prod/vidply.VimeoRenderer-P3PU27S7.min.js +6 -0
  33. package/dist/prod/vidply.YouTubeRenderer-DGKKWB5M.min.js +6 -0
  34. package/dist/prod/vidply.chunk-BQBGEJF7.min.js +6 -0
  35. package/dist/prod/vidply.chunk-MBUR3U5L.min.js +6 -0
  36. package/dist/prod/vidply.de-SWFW4HYT.min.js +6 -0
  37. package/dist/prod/vidply.es-7BJ2DJAY.min.js +6 -0
  38. package/dist/prod/vidply.esm.min.js +21 -0
  39. package/dist/prod/vidply.fr-DPVR5DFY.min.js +6 -0
  40. package/dist/prod/vidply.ja-PEBVWKVH.min.js +6 -0
  41. package/dist/vidply.css +184 -4
  42. package/dist/vidply.esm.min.meta.json +284 -102
  43. package/dist/vidply.min.css +1 -1
  44. package/package.json +4 -4
  45. package/src/controls/ControlBar.js +3341 -3246
  46. package/src/controls/TranscriptManager.js +2296 -2271
  47. package/src/core/Player.js +4807 -4730
  48. package/src/features/PlaylistManager.js +1203 -1039
  49. package/src/i18n/i18n.js +51 -7
  50. package/src/i18n/languages/de.js +5 -1
  51. package/src/i18n/languages/en.js +5 -1
  52. package/src/i18n/languages/es.js +5 -1
  53. package/src/i18n/languages/fr.js +5 -1
  54. package/src/i18n/languages/ja.js +5 -1
  55. package/src/i18n/translations.js +35 -18
  56. package/src/icons/Icons.js +2 -20
  57. package/src/renderers/HLSRenderer.js +7 -0
  58. package/src/styles/vidply.css +184 -4
  59. package/src/utils/DOMUtils.js +67 -0
  60. package/src/utils/MenuUtils.js +10 -4
  61. package/src/utils/SettingsMenuFactory.js +8 -4
  62. package/src/utils/WindowComponents.js +6 -4
  63. package/dist/vidply.esm.js.map +0 -7
  64. package/dist/vidply.esm.min.js +0 -18
  65. package/dist/vidply.js.map +0 -7
  66. package/dist/vidply.min.js +0 -18
@@ -1,2271 +1,2296 @@
1
- /**
2
- * Transcript Manager Component
3
- * Manages transcript display and interaction
4
- */
5
-
6
- import { DOMUtils } from '../utils/DOMUtils.js';
7
- import { TimeUtils } from '../utils/TimeUtils.js';
8
- import { createIconElement } from '../icons/Icons.js';
9
- import { i18n } from '../i18n/i18n.js';
10
- import { StorageManager } from '../utils/StorageManager.js';
11
- import { focusElement, focusFirstElement } from '../utils/FocusUtils.js';
12
- import { createMenuItem, attachMenuKeyboardNavigation, focusFirstMenuItem } from '../utils/MenuUtils.js';
13
- import { DraggableResizable } from '../utils/DraggableResizable.js';
14
- import { createLabeledSelect, toggleLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
15
-
16
- export class TranscriptManager {
17
- constructor(player) {
18
- this.player = player;
19
- this.transcriptWindow = null;
20
- this.transcriptEntries = [];
21
- this.metadataCues = [];
22
- this.currentActiveEntry = null;
23
- this.isVisible = false;
24
-
25
- // Storage manager
26
- this.storage = new StorageManager('vidply');
27
-
28
- // Draggable/Resizable utility
29
- this.draggableResizable = null;
30
-
31
- // Settings menu state
32
- this.settingsMenuVisible = false;
33
- this.settingsMenu = null;
34
- this.settingsButton = null;
35
- this.settingsMenuJustOpened = false;
36
-
37
- // Resize mode state
38
- this.resizeOptionButton = null;
39
- this.resizeOptionText = null;
40
- this.dragOptionButton = null;
41
- this.dragOptionText = null;
42
- this.resizeModeIndicator = null;
43
- this.resizeModeIndicatorTimeout = null;
44
- this.transcriptResizeHandles = [];
45
- this.liveRegion = null;
46
-
47
- // Style dialog state
48
- this.styleDialog = null;
49
- this.styleDialogVisible = false;
50
- this.styleDialogJustOpened = false;
51
-
52
- // Language selector state
53
- this.languageSelector = null;
54
- this.languageLabel = null;
55
- this.currentTranscriptLanguage = null;
56
- this.availableTranscriptLanguages = [];
57
- this.languageSelectorHandler = null;
58
-
59
- // Load saved preferences from localStorage
60
- const savedPreferences = this.storage.getTranscriptPreferences();
61
-
62
- // Autoscroll state (default: true)
63
- this.autoscrollEnabled = savedPreferences?.autoscroll !== undefined ? savedPreferences.autoscroll : true;
64
-
65
- // Show timestamps state (default: false)
66
- this.showTimestamps = savedPreferences?.showTimestamps !== undefined ? savedPreferences.showTimestamps : false;
67
-
68
- // Transcript styling options (with defaults, then player options, then saved preferences)
69
- this.transcriptStyle = {
70
- fontSize: savedPreferences?.fontSize || this.player.options.transcriptFontSize || '100%',
71
- fontFamily: savedPreferences?.fontFamily || this.player.options.transcriptFontFamily || 'sans-serif',
72
- color: savedPreferences?.color || this.player.options.transcriptColor || '#ffffff',
73
- backgroundColor: savedPreferences?.backgroundColor || this.player.options.transcriptBackgroundColor || '#1e1e1e',
74
- opacity: savedPreferences?.opacity ?? this.player.options.transcriptOpacity ?? 0.98
75
- };
76
-
77
- // Store event handlers for cleanup
78
- this.handlers = {
79
- timeupdate: () => this.updateActiveEntry(),
80
- audiodescriptionenabled: () => {
81
- if (this.isVisible) {
82
- this.loadTranscriptData();
83
- }
84
- },
85
- audiodescriptiondisabled: () => {
86
- if (this.isVisible) {
87
- this.loadTranscriptData();
88
- }
89
- },
90
- resize: null,
91
- settingsClick: null,
92
- settingsKeydown: null,
93
- documentClick: null,
94
- styleDialogKeydown: null
95
- };
96
-
97
- // Timeout management (for cleanup)
98
- this.timeouts = new Set();
99
-
100
- this.init();
101
- }
102
-
103
- init() {
104
- // Set up metadata handling immediately (independent of transcript display)
105
- this.setupMetadataHandlingOnLoad();
106
-
107
- // Listen for time updates to highlight active transcript entry
108
- this.player.on('timeupdate', this.handlers.timeupdate);
109
-
110
- // Listen for audio description changes to reload transcript
111
- this.player.on('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
112
- this.player.on('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
113
-
114
- // Reposition transcript when entering/exiting fullscreen
115
- this.player.on('fullscreenchange', () => {
116
- if (this.isVisible) {
117
- // Re-setup drag/drop when entering/exiting fullscreen on mobile devices
118
- // This enables drag/resize when entering fullscreen on mobile
119
- const isMobile = window.innerWidth < 768;
120
- if (isMobile) {
121
- this.setupDragAndDrop();
122
- }
123
-
124
- // Only auto-position if user hasn't manually positioned it
125
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
126
- // Add a small delay to ensure DOM has updated after fullscreen transition
127
- this.setManagedTimeout(() => this.positionTranscript(), 100);
128
- }
129
- }
130
- });
131
- }
132
-
133
- /**
134
- * Toggle transcript window visibility
135
- */
136
- toggleTranscript() {
137
- if (this.isVisible) {
138
- this.hideTranscript();
139
- } else {
140
- this.showTranscript();
141
- }
142
- }
143
-
144
- /**
145
- * Show transcript window
146
- */
147
- showTranscript() {
148
- if (this.transcriptWindow) {
149
- this.transcriptWindow.style.display = 'flex';
150
- this.isVisible = true;
151
-
152
- if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
153
- this.player.controlBar.updateTranscriptButton();
154
- }
155
-
156
- // Focus the settings button for keyboard accessibility
157
- focusElement(this.settingsButton, { delay: 150 });
158
- return;
159
- }
160
-
161
- // Create transcript window
162
- this.createTranscriptWindow();
163
- this.loadTranscriptData();
164
-
165
- // Show the window
166
- if (this.transcriptWindow) {
167
- this.transcriptWindow.style.display = 'flex';
168
-
169
- // Only auto-position if user hasn't manually positioned it
170
- // This prevents overwriting saved positions from localStorage
171
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
172
- this.setManagedTimeout(() => this.positionTranscript(), 0);
173
- }
174
-
175
- // Focus the settings button for keyboard accessibility
176
- focusElement(this.settingsButton, { delay: 150 });
177
- }
178
- this.isVisible = true;
179
- }
180
-
181
- /**
182
- * Hide transcript window
183
- */
184
- hideTranscript({ focusButton = false } = {}) {
185
- if (this.transcriptWindow) {
186
- this.transcriptWindow.style.display = 'none';
187
- this.isVisible = false;
188
- }
189
- if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
190
- this.draggableResizable.disablePointerResizeMode();
191
- this.updateResizeOptionState();
192
- }
193
- this.hideResizeModeIndicator();
194
- this.announceLive('');
195
-
196
- // Update transcript button state in control bar
197
- if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
198
- this.player.controlBar.updateTranscriptButton();
199
- }
200
-
201
- if (focusButton) {
202
- const transcriptButton = this.player.controlBar?.controls?.transcript;
203
- if (transcriptButton && typeof transcriptButton.focus === 'function') {
204
- transcriptButton.focus();
205
- }
206
- }
207
- }
208
-
209
- /**
210
- * Create the transcript window UI
211
- */
212
- createTranscriptWindow() {
213
- this.transcriptWindow = DOMUtils.createElement('div', {
214
- className: `${this.player.options.classPrefix}-transcript-window`,
215
- attributes: {
216
- 'role': 'dialog',
217
- 'aria-label': 'Video Transcript',
218
- 'tabindex': '-1'
219
- }
220
- });
221
-
222
- // Header (draggable)
223
- this.transcriptHeader = DOMUtils.createElement('div', {
224
- className: `${this.player.options.classPrefix}-transcript-header`,
225
- attributes: {
226
- 'tabindex': '0'
227
- }
228
- });
229
-
230
- // Header left side (settings button + title)
231
- this.headerLeft = DOMUtils.createElement('div', {
232
- className: `${this.player.options.classPrefix}-transcript-header-left`
233
- });
234
-
235
- // Settings button
236
- this.settingsButton = DOMUtils.createElement('button', {
237
- className: `${this.player.options.classPrefix}-transcript-settings`,
238
- attributes: {
239
- 'type': 'button',
240
- 'aria-label': i18n.t('transcript.settingsMenu'),
241
- 'aria-expanded': 'false'
242
- }
243
- });
244
- this.settingsButton.appendChild(createIconElement('settings'));
245
- this.handlers.settingsClick = (e) => {
246
- e.preventDefault();
247
- e.stopPropagation();
248
- if (this.settingsMenuVisible) {
249
- this.hideSettingsMenu();
250
- } else {
251
- this.showSettingsMenu();
252
- }
253
- };
254
- this.settingsButton.addEventListener('click', this.handlers.settingsClick);
255
-
256
- // Keyboard handler for settings button
257
- this.handlers.settingsKeydown = (e) => {
258
- // D key to toggle keyboard drag mode
259
- if (e.key === 'd' || e.key === 'D') {
260
- e.preventDefault();
261
- e.stopPropagation();
262
- this.toggleKeyboardDragMode();
263
- }
264
- // R key to toggle resize mode
265
- else if (e.key === 'r' || e.key === 'R') {
266
- e.preventDefault();
267
- e.stopPropagation();
268
- this.toggleResizeMode();
269
- }
270
- // Escape to close menu if open
271
- else if (e.key === 'Escape' && this.settingsMenuVisible) {
272
- e.preventDefault();
273
- e.stopPropagation();
274
- this.hideSettingsMenu();
275
- }
276
- };
277
- this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
278
-
279
- const title = DOMUtils.createElement('h3', {
280
- textContent: `${i18n.t('transcript.title')}. ${i18n.t('transcript.dragResizePrompt')}`
281
- });
282
-
283
- // Autoscroll checkbox
284
- const autoscrollId = `${this.player.options.classPrefix}-transcript-autoscroll-${Date.now()}`;
285
-
286
- const autoscrollLabel = DOMUtils.createElement('label', {
287
- className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
288
- attributes: {
289
- 'for': autoscrollId,
290
- 'title': i18n.t('transcript.autoscroll')
291
- }
292
- });
293
-
294
- this.autoscrollCheckbox = DOMUtils.createElement('input', {
295
- attributes: {
296
- 'id': autoscrollId,
297
- 'type': 'checkbox'
298
- }
299
- });
300
- // Set checked property directly (boolean attribute, not "true" string)
301
- if (this.autoscrollEnabled) {
302
- this.autoscrollCheckbox.checked = true;
303
- }
304
-
305
- const autoscrollText = DOMUtils.createElement('span', {
306
- textContent: i18n.t('transcript.autoscroll'),
307
- className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
308
- });
309
-
310
- autoscrollLabel.appendChild(this.autoscrollCheckbox);
311
- autoscrollLabel.appendChild(autoscrollText);
312
-
313
- // Handle autoscroll checkbox change
314
- this.autoscrollCheckbox.addEventListener('change', (e) => {
315
- this.autoscrollEnabled = e.target.checked;
316
- this.saveAutoscrollPreference();
317
- });
318
-
319
- this.transcriptHeader.appendChild(title);
320
- this.headerLeft.appendChild(this.settingsButton);
321
- this.headerLeft.appendChild(autoscrollLabel);
322
-
323
- // Language selector (will be populated after tracks are loaded)
324
- const selectId = `${this.player.options.classPrefix}-transcript-language-select-${Date.now()}`;
325
- const { label: languageLabel, select: languageSelector } = createLabeledSelect({
326
- classPrefix: this.player.options.classPrefix,
327
- labelClass: `${this.player.options.classPrefix}-transcript-language-label`,
328
- selectClass: `${this.player.options.classPrefix}-transcript-language-select`,
329
- labelText: 'settings.language',
330
- selectId: selectId,
331
- hidden: false // Don't hide individual elements, we'll hide the wrapper instead
332
- });
333
-
334
- this.languageLabel = languageLabel;
335
- this.languageSelector = languageSelector;
336
-
337
- // Wrap label and select in a container for vertical stacking
338
- const languageSelectorWrapper = DOMUtils.createElement('div', {
339
- className: `${this.player.options.classPrefix}-transcript-language-wrapper`,
340
- attributes: {
341
- 'style': 'display: none;' // Hidden until we detect multiple languages
342
- }
343
- });
344
- languageSelectorWrapper.appendChild(this.languageLabel);
345
- languageSelectorWrapper.appendChild(this.languageSelector);
346
- this.languageSelectorWrapper = languageSelectorWrapper;
347
-
348
- // Prevent drag when interacting with wrapper
349
- preventDragOnElement(languageSelectorWrapper);
350
-
351
- this.headerLeft.appendChild(languageSelectorWrapper);
352
-
353
- const closeButton = DOMUtils.createElement('button', {
354
- className: `${this.player.options.classPrefix}-transcript-close`,
355
- attributes: {
356
- 'type': 'button',
357
- 'aria-label': i18n.t('transcript.close')
358
- }
359
- });
360
- closeButton.appendChild(createIconElement('close'));
361
- closeButton.addEventListener('click', () => this.hideTranscript({ focusButton: true }));
362
-
363
- this.transcriptHeader.appendChild(this.headerLeft);
364
- this.transcriptHeader.appendChild(closeButton);
365
-
366
- // Content container
367
- this.transcriptContent = DOMUtils.createElement('div', {
368
- className: `${this.player.options.classPrefix}-transcript-content`
369
- });
370
-
371
- this.transcriptWindow.appendChild(this.transcriptHeader);
372
- this.transcriptWindow.appendChild(this.transcriptContent);
373
-
374
- this.createResizeHandles();
375
-
376
- // Live region for announcements (screen reader feedback)
377
- this.liveRegion = DOMUtils.createElement('div', {
378
- className: 'vidply-sr-only',
379
- attributes: {
380
- 'aria-live': 'polite',
381
- 'aria-atomic': 'true'
382
- }
383
- });
384
- this.transcriptWindow.appendChild(this.liveRegion);
385
-
386
- // Append to player container
387
- this.player.container.appendChild(this.transcriptWindow);
388
-
389
- // Setup drag functionality FIRST (this will restore saved position if it exists)
390
- this.setupDragAndDrop();
391
-
392
- // Then position it next to the video wrapper ONLY if user hasn't manually positioned it
393
- // This ensures we don't overwrite saved positions from localStorage
394
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
395
- this.positionTranscript();
396
- }
397
-
398
- // Setup document click handler to close settings menu and style dialog
399
- // DON'T add it yet - it will be added when the menu is first opened
400
- this.handlers.documentClick = (e) => {
401
- // Ignore if menu was just opened (prevents immediate closing)
402
- if (this.settingsMenuJustOpened) {
403
- return;
404
- }
405
-
406
- // Ignore if style dialog was just opened (prevents immediate closing)
407
- if (this.styleDialogJustOpened) {
408
- return;
409
- }
410
-
411
- // Ignore clicks on the settings button itself
412
- if (this.settingsButton && this.settingsButton.contains(e.target)) {
413
- return;
414
- }
415
-
416
- // Ignore clicks on the settings menu items
417
- if (this.settingsMenu && this.settingsMenu.contains(e.target)) {
418
- return;
419
- }
420
-
421
- // Close settings menu if clicking outside
422
- if (this.settingsMenuVisible) {
423
- this.hideSettingsMenu();
424
- }
425
-
426
- // Close style dialog if clicking outside (but not on settings button)
427
- if (this.styleDialogVisible && this.styleDialog &&
428
- !this.styleDialog.contains(e.target)) {
429
- this.hideStyleDialog();
430
- }
431
- };
432
- // Store flag to track if handler has been added
433
- this.documentClickHandlerAdded = false;
434
-
435
- // Re-position on window resize (debounced) - but only if not manually positioned
436
- let resizeTimeout;
437
- this.handlers.resize = () => {
438
- if (resizeTimeout) {
439
- this.clearManagedTimeout(resizeTimeout);
440
- }
441
- resizeTimeout = this.setManagedTimeout(() => {
442
- // Only auto-position if user hasn't manually moved it
443
- if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
444
- this.positionTranscript();
445
- }
446
- }, 100);
447
- };
448
- window.addEventListener('resize', this.handlers.resize);
449
- }
450
-
451
- createResizeHandles() {
452
- if (!this.transcriptWindow) return;
453
-
454
- const directions = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
455
- this.transcriptResizeHandles = directions.map(direction => {
456
- const handle = DOMUtils.createElement('div', {
457
- className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
458
- attributes: {
459
- 'data-direction': direction,
460
- 'data-vidply-managed-resize': 'true',
461
- 'aria-hidden': 'true'
462
- }
463
- });
464
-
465
- handle.style.display = 'none';
466
- this.transcriptWindow.appendChild(handle);
467
- return handle;
468
- });
469
- }
470
-
471
- /**
472
- * Position transcript window next to video
473
- */
474
- positionTranscript() {
475
- if (!this.transcriptWindow || !this.player.videoWrapper || !this.isVisible) return;
476
-
477
- // Don't auto-position if user has manually positioned it
478
- if (this.draggableResizable && this.draggableResizable.manuallyPositioned) {
479
- return;
480
- }
481
-
482
- const isMobile = window.innerWidth < 768;
483
- const videoRect = this.player.videoWrapper.getBoundingClientRect();
484
-
485
- // Check if player is in fullscreen mode
486
- const isFullscreen = this.player.state.fullscreen;
487
-
488
- if (isMobile && !isFullscreen) {
489
- // Mobile: Position underneath the video and controls as part of the layout
490
- this.transcriptWindow.style.position = 'relative';
491
- this.transcriptWindow.style.left = '0';
492
- this.transcriptWindow.style.right = '0';
493
- this.transcriptWindow.style.bottom = 'auto';
494
- this.transcriptWindow.style.top = 'auto';
495
- this.transcriptWindow.style.width = '100%';
496
- this.transcriptWindow.style.maxWidth = '100%';
497
- this.transcriptWindow.style.maxHeight = '400px';
498
- this.transcriptWindow.style.height = 'auto';
499
- this.transcriptWindow.style.borderRadius = '0';
500
- this.transcriptWindow.style.transform = 'none';
501
- this.transcriptWindow.style.border = 'none';
502
- this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
503
- // Remove any empty border properties that might have been set
504
- this.transcriptWindow.style.removeProperty('border-right');
505
- this.transcriptWindow.style.removeProperty('border-bottom');
506
- this.transcriptWindow.style.removeProperty('border-left');
507
- // Remove border-image properties that can cause parse errors
508
- this.transcriptWindow.style.removeProperty('border-image');
509
- this.transcriptWindow.style.removeProperty('border-image-source');
510
- this.transcriptWindow.style.removeProperty('border-image-slice');
511
- this.transcriptWindow.style.removeProperty('border-image-width');
512
- this.transcriptWindow.style.removeProperty('border-image-outset');
513
- this.transcriptWindow.style.removeProperty('border-image-repeat');
514
- this.transcriptWindow.style.boxShadow = 'none';
515
- // Disable dragging on mobile
516
- if (this.transcriptHeader) {
517
- this.transcriptHeader.style.cursor = 'default';
518
- }
519
-
520
- // Ensure transcript is at the container level for proper stacking
521
- if (this.transcriptWindow.parentNode !== this.player.container) {
522
- this.player.container.appendChild(this.transcriptWindow);
523
- }
524
- } else if (isFullscreen) {
525
- // In fullscreen: position in bottom right corner inside the video
526
- this.transcriptWindow.style.position = 'fixed';
527
- this.transcriptWindow.style.left = 'auto';
528
- this.transcriptWindow.style.right = '20px';
529
- this.transcriptWindow.style.bottom = '80px'; // Above controls
530
- this.transcriptWindow.style.top = 'auto';
531
- this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
532
- this.transcriptWindow.style.height = 'auto';
533
- const fullscreenMinWidth = 260;
534
- const fullscreenAvailable = Math.max(fullscreenMinWidth, window.innerWidth - 40);
535
- const fullscreenDesired = parseFloat(this.transcriptWindow.style.width) || 400;
536
- const fullscreenWidth = Math.max(fullscreenMinWidth, Math.min(fullscreenDesired, fullscreenAvailable));
537
- this.transcriptWindow.style.width = `${fullscreenWidth}px`;
538
- this.transcriptWindow.style.maxWidth = 'none';
539
- this.transcriptWindow.style.borderRadius = '8px';
540
- this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
541
- // Remove borderTop and any other individual border properties to avoid empty values
542
- this.transcriptWindow.style.removeProperty('border-top');
543
- this.transcriptWindow.style.removeProperty('border-right');
544
- this.transcriptWindow.style.removeProperty('border-bottom');
545
- this.transcriptWindow.style.removeProperty('border-left');
546
- // Remove border-image properties that can cause parse errors
547
- this.transcriptWindow.style.removeProperty('border-image');
548
- this.transcriptWindow.style.removeProperty('border-image-source');
549
- this.transcriptWindow.style.removeProperty('border-image-slice');
550
- this.transcriptWindow.style.removeProperty('border-image-width');
551
- this.transcriptWindow.style.removeProperty('border-image-outset');
552
- this.transcriptWindow.style.removeProperty('border-image-repeat');
553
- // Enable dragging in fullscreen (including touch devices)
554
- if (this.transcriptHeader) {
555
- this.transcriptHeader.style.cursor = 'move';
556
- }
557
-
558
- // Move back to container for fullscreen
559
- if (this.transcriptWindow.parentNode !== this.player.container) {
560
- this.player.container.appendChild(this.transcriptWindow);
561
- }
562
- } else {
563
- // Desktop mode: position in right side of viewport
564
- const transcriptWidth = parseFloat(this.transcriptWindow.style.width) || 400;
565
- const padding = 20;
566
- const minWidth = 260;
567
- const containerRect = this.player.container.getBoundingClientRect();
568
-
569
- const ensureContainerPositioned = () => {
570
- const computed = window.getComputedStyle(this.player.container);
571
- if (computed.position === 'static') {
572
- this.player.container.style.position = 'relative';
573
- }
574
- };
575
-
576
- ensureContainerPositioned();
577
-
578
- const left = (videoRect.right - containerRect.left) + padding;
579
- const availableWidth = window.innerWidth - videoRect.right - padding;
580
- const appliedWidth = Math.max(minWidth, Math.min(transcriptWidth, availableWidth));
581
- const appliedHeight = videoRect.height;
582
-
583
- this.transcriptWindow.style.position = 'absolute';
584
- this.transcriptWindow.style.left = `${left}px`;
585
- this.transcriptWindow.style.right = 'auto';
586
- this.transcriptWindow.style.bottom = 'auto';
587
- this.transcriptWindow.style.top = '0';
588
- this.transcriptWindow.style.height = `${appliedHeight}px`;
589
- this.transcriptWindow.style.maxHeight = 'none';
590
- this.transcriptWindow.style.width = `${appliedWidth}px`;
591
- this.transcriptWindow.style.maxWidth = 'none';
592
- this.transcriptWindow.style.borderRadius = '8px';
593
- this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
594
- // Remove borderTop and any other individual border properties to avoid empty values
595
- this.transcriptWindow.style.removeProperty('border-top');
596
- this.transcriptWindow.style.removeProperty('border-right');
597
- this.transcriptWindow.style.removeProperty('border-bottom');
598
- this.transcriptWindow.style.removeProperty('border-left');
599
- // Remove border-image properties that can cause parse errors
600
- this.transcriptWindow.style.removeProperty('border-image');
601
- this.transcriptWindow.style.removeProperty('border-image-source');
602
- this.transcriptWindow.style.removeProperty('border-image-slice');
603
- this.transcriptWindow.style.removeProperty('border-image-width');
604
- this.transcriptWindow.style.removeProperty('border-image-outset');
605
- this.transcriptWindow.style.removeProperty('border-image-repeat');
606
- // Enable dragging on desktop
607
- if (this.transcriptHeader) {
608
- this.transcriptHeader.style.cursor = 'move';
609
- }
610
-
611
- // Move back to container for desktop
612
- if (this.transcriptWindow.parentNode !== this.player.container) {
613
- this.player.container.appendChild(this.transcriptWindow);
614
- }
615
- }
616
- }
617
-
618
- /**
619
- * Get available transcript languages from tracks
620
- */
621
- getAvailableTranscriptLanguages() {
622
- const textTracks = this.player.textTracks;
623
- const languages = new Map();
624
-
625
- // Collect all caption/subtitle tracks with their languages
626
- textTracks.forEach(track => {
627
- if ((track.kind === 'captions' || track.kind === 'subtitles') && track.language) {
628
- if (!languages.has(track.language)) {
629
- languages.set(track.language, {
630
- language: track.language,
631
- label: track.label || track.language,
632
- track: track
633
- });
634
- }
635
- }
636
- });
637
-
638
- return Array.from(languages.values());
639
- }
640
-
641
- /**
642
- * Update language selector dropdown
643
- */
644
- updateLanguageSelector() {
645
- if (!this.languageSelector) return;
646
-
647
- this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
648
-
649
- // Clear existing options
650
- this.languageSelector.innerHTML = '';
651
-
652
- // Only show selector if there are 2+ languages
653
- if (this.availableTranscriptLanguages.length < 2) {
654
- if (this.languageSelectorWrapper) {
655
- this.languageSelectorWrapper.style.display = 'none';
656
- }
657
- return;
658
- }
659
-
660
- // Show selector wrapper
661
- if (this.languageSelectorWrapper) {
662
- this.languageSelectorWrapper.style.display = 'flex';
663
- }
664
-
665
- this.availableTranscriptLanguages.forEach((langInfo, index) => {
666
- const option = DOMUtils.createElement('option', {
667
- textContent: langInfo.label,
668
- attributes: {
669
- 'value': langInfo.language,
670
- 'lang': langInfo.language
671
- }
672
- });
673
- this.languageSelector.appendChild(option);
674
- });
675
-
676
- // Set current selection
677
- if (this.currentTranscriptLanguage) {
678
- this.languageSelector.value = this.currentTranscriptLanguage;
679
- } else if (this.availableTranscriptLanguages.length > 0) {
680
- // Default to first language or active track
681
- const activeTrack = this.player.textTracks.find(
682
- track => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
683
- );
684
- this.currentTranscriptLanguage = activeTrack ? activeTrack.language : this.availableTranscriptLanguages[0].language;
685
- this.languageSelector.value = this.currentTranscriptLanguage;
686
- }
687
-
688
- // Remove existing change listener if any
689
- if (this.languageSelectorHandler) {
690
- this.languageSelector.removeEventListener('change', this.languageSelectorHandler);
691
- }
692
-
693
- // Handle language change
694
- this.languageSelectorHandler = (e) => {
695
- this.currentTranscriptLanguage = e.target.value;
696
- this.loadTranscriptData();
697
-
698
- // Set lang attribute for screen readers to pronounce text correctly
699
- if (this.transcriptContent && this.currentTranscriptLanguage) {
700
- this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
701
- }
702
- };
703
- this.languageSelector.addEventListener('change', this.languageSelectorHandler);
704
- }
705
-
706
- /**
707
- * Load transcript data from caption/subtitle tracks
708
- */
709
- loadTranscriptData() {
710
- this.transcriptEntries = [];
711
- this.transcriptContent.innerHTML = '';
712
-
713
- // Get all text tracks
714
- const textTracks = this.player.textTracks;
715
-
716
- // Find track for selected language, or default to first available
717
- let captionTrack = null;
718
- if (this.currentTranscriptLanguage) {
719
- captionTrack = textTracks.find(
720
- track => (track.kind === 'captions' || track.kind === 'subtitles') &&
721
- track.language === this.currentTranscriptLanguage
722
- );
723
- }
724
-
725
- // Fallback to first available caption/subtitle track
726
- if (!captionTrack) {
727
- captionTrack = textTracks.find(
728
- track => track.kind === 'captions' || track.kind === 'subtitles'
729
- );
730
- if (captionTrack) {
731
- this.currentTranscriptLanguage = captionTrack.language;
732
- }
733
- }
734
-
735
- // Find description track matching the selected language
736
- let descriptionTrack = null;
737
- if (this.currentTranscriptLanguage) {
738
- descriptionTrack = textTracks.find(
739
- track => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
740
- );
741
- }
742
- // Fallback to first available description track if no match found
743
- if (!descriptionTrack) {
744
- descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
745
- }
746
-
747
- const metadataTrack = textTracks.find(track => track.kind === 'metadata');
748
-
749
- // We need at least one track type available for display
750
- // Description tracks are only included if audio description is enabled
751
- const hasDescriptionTrack = descriptionTrack && this.player.state.audioDescriptionEnabled;
752
- if (!captionTrack && !hasDescriptionTrack && !metadataTrack) {
753
- this.showNoTranscriptMessage();
754
- return;
755
- }
756
-
757
- // Enable all tracks to load cues (even if we won't display descriptions)
758
- // This ensures descriptions are ready when audio description is enabled
759
- const tracksToLoad = [captionTrack, descriptionTrack, metadataTrack].filter(Boolean);
760
- tracksToLoad.forEach(track => {
761
- if (track.mode === 'disabled') {
762
- track.mode = 'hidden';
763
- }
764
- });
765
-
766
- // Check if any tracks are still loading
767
- const needsLoading = tracksToLoad.some(track => !track.cues || track.cues.length === 0);
768
-
769
- if (needsLoading) {
770
- // Wait for cues to load
771
- const loadingMessage = DOMUtils.createElement('div', {
772
- className: `${this.player.options.classPrefix}-transcript-loading`,
773
- textContent: i18n.t('transcript.loading')
774
- });
775
- this.transcriptContent.appendChild(loadingMessage);
776
-
777
- let loaded = 0;
778
- const onLoad = () => {
779
- loaded++;
780
- if (loaded >= tracksToLoad.length) {
781
- this.loadTranscriptData();
782
- }
783
- };
784
-
785
- tracksToLoad.forEach(track => {
786
- track.addEventListener('load', onLoad, { once: true });
787
- });
788
-
789
- // Fallback timeout
790
- this.setManagedTimeout(() => {
791
- this.loadTranscriptData();
792
- }, 500);
793
-
794
- return;
795
- }
796
-
797
- // Collect all cues from all tracks with their type
798
- const allCues = [];
799
-
800
- if (captionTrack && captionTrack.cues) {
801
- Array.from(captionTrack.cues).forEach(cue => {
802
- allCues.push({ cue, type: 'caption' });
803
- });
804
- }
805
-
806
- // Only include description cues if audio description is enabled
807
- if (descriptionTrack && descriptionTrack.cues && this.player.state.audioDescriptionEnabled) {
808
- Array.from(descriptionTrack.cues).forEach(cue => {
809
- allCues.push({ cue, type: 'description' });
810
- });
811
- }
812
-
813
- // Store metadata separately for programmatic use (don't display in transcript)
814
- if (metadataTrack && metadataTrack.cues) {
815
- this.metadataCues = Array.from(metadataTrack.cues);
816
- this.setupMetadataHandling();
817
- }
818
-
819
- // Sort all cues by start time
820
- allCues.sort((a, b) => a.cue.startTime - b.cue.startTime);
821
-
822
- // Build transcript from captions and descriptions only
823
- allCues.forEach((item, index) => {
824
- const entry = this.createTranscriptEntry(item.cue, index, item.type);
825
- this.transcriptEntries.push({
826
- element: entry,
827
- cue: item.cue,
828
- type: item.type,
829
- startTime: item.cue.startTime,
830
- endTime: item.cue.endTime
831
- });
832
- this.transcriptContent.appendChild(entry);
833
- });
834
-
835
- // Apply current styles to newly loaded entries
836
- this.applyTranscriptStyles();
837
-
838
- // Apply timestamp visibility preference
839
- this.updateTimestampVisibility();
840
-
841
- // Set lang attribute for screen readers to pronounce text correctly
842
- if (this.transcriptContent && this.currentTranscriptLanguage) {
843
- this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
844
- }
845
-
846
- // Update language selector after loading
847
- this.updateLanguageSelector();
848
- }
849
-
850
- /**
851
- * Setup metadata handling on player load
852
- * This runs independently of transcript loading
853
- */
854
- setupMetadataHandlingOnLoad() {
855
- // Wait for metadata to be loaded
856
- const setupMetadata = () => {
857
- const textTracks = this.player.textTracks;
858
- const metadataTrack = textTracks.find(track => track.kind === 'metadata');
859
-
860
- if (metadataTrack) {
861
- // Enable the metadata track so cuechange events fire
862
- // Use 'hidden' mode so it doesn't display anything, but events still work
863
- if (metadataTrack.mode === 'disabled') {
864
- metadataTrack.mode = 'hidden';
865
- }
866
-
867
- // Check if we already added the listener
868
- if (this.metadataCueChangeHandler) {
869
- metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
870
- }
871
-
872
- // Add event listener for cue changes
873
- this.metadataCueChangeHandler = () => {
874
- const activeCues = Array.from(metadataTrack.activeCues || []);
875
- if (activeCues.length > 0) {
876
- // Debug logging (can be removed in production)
877
- if (this.player.options.debug) {
878
- console.log('[VidPly Metadata] Active cues:', activeCues.map(c => ({
879
- start: c.startTime,
880
- end: c.endTime,
881
- text: c.text
882
- })));
883
- }
884
- }
885
- activeCues.forEach(cue => {
886
- this.handleMetadataCue(cue);
887
- });
888
- };
889
-
890
- metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
891
-
892
- // Debug: Log metadata track setup
893
- if (this.player.options.debug) {
894
- const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
895
- console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
896
- }
897
- } else if (this.player.options.debug) {
898
- console.warn('[VidPly Metadata] No metadata track found');
899
- }
900
- };
901
-
902
- // Try immediately
903
- setupMetadata();
904
-
905
- // Also try after loadedmetadata event
906
- this.player.on('loadedmetadata', setupMetadata);
907
- }
908
-
909
- /**
910
- * Setup metadata handling
911
- * Metadata cues are not displayed but can be used programmatically
912
- * This is called when transcript data is loaded (for storing cues)
913
- */
914
- setupMetadataHandling() {
915
- if (!this.metadataCues || this.metadataCues.length === 0) {
916
- return;
917
- }
918
-
919
- // The actual event handling is set up in setupMetadataHandlingOnLoad()
920
- // This method just stores the cues for reference
921
- if (this.player.options.debug) {
922
- console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
923
- }
924
- }
925
-
926
- /**
927
- * Handle individual metadata cues
928
- * Parses metadata text and emits events or triggers actions
929
- */
930
- handleMetadataCue(cue) {
931
- const text = cue.text.trim();
932
-
933
- // Debug logging
934
- if (this.player.options.debug) {
935
- console.log('[VidPly Metadata] Processing cue:', {
936
- time: cue.startTime,
937
- text: text
938
- });
939
- }
940
-
941
- // Emit a generic metadata event that developers can listen to
942
- this.player.emit('metadata', {
943
- time: cue.startTime,
944
- endTime: cue.endTime,
945
- text: text,
946
- cue: cue
947
- });
948
-
949
- // Parse for specific commands (examples based on wwa_meta.vtt format)
950
- if (text.includes('PAUSE')) {
951
- // Automatically pause the video
952
- if (!this.player.state.paused) {
953
- if (this.player.options.debug) {
954
- console.log('[VidPly Metadata] Pausing video at', cue.startTime);
955
- }
956
- this.player.pause();
957
- }
958
- // Also emit event for developers who want to listen
959
- this.player.emit('metadata:pause', { time: cue.startTime, text: text });
960
- }
961
-
962
- // Parse for focus directives
963
- const focusMatch = text.match(/FOCUS:([\w#-]+)/);
964
- if (focusMatch) {
965
- const targetSelector = focusMatch[1];
966
- // Automatically focus the target element
967
- const targetElement = document.querySelector(targetSelector);
968
- if (targetElement) {
969
- if (this.player.options.debug) {
970
- console.log('[VidPly Metadata] Focusing element:', targetSelector);
971
- }
972
- // Use setTimeout to ensure DOM is ready
973
- this.setManagedTimeout(() => {
974
- targetElement.focus();
975
- }, 10);
976
- } else if (this.player.options.debug) {
977
- console.warn('[VidPly Metadata] Element not found:', targetSelector);
978
- }
979
- // Also emit event for developers who want to listen
980
- this.player.emit('metadata:focus', {
981
- time: cue.startTime,
982
- target: targetSelector,
983
- element: targetElement,
984
- text: text
985
- });
986
- }
987
-
988
- // Parse for hashtag references
989
- const hashtags = text.match(/#[\w-]+/g);
990
- if (hashtags) {
991
- if (this.player.options.debug) {
992
- console.log('[VidPly Metadata] Hashtags found:', hashtags);
993
- }
994
- this.player.emit('metadata:hashtags', {
995
- time: cue.startTime,
996
- hashtags: hashtags,
997
- text: text
998
- });
999
- }
1000
- }
1001
-
1002
- /**
1003
- * Create a single transcript entry element
1004
- */
1005
- createTranscriptEntry(cue, index, type = 'caption') {
1006
- const entryText = this.stripVTTFormatting(cue.text);
1007
-
1008
- const entry = DOMUtils.createElement('div', {
1009
- className: `${this.player.options.classPrefix}-transcript-entry ${this.player.options.classPrefix}-transcript-${type}`,
1010
- attributes: {
1011
- 'tabindex': '0',
1012
- 'data-start': String(cue.startTime),
1013
- 'data-end': String(cue.endTime),
1014
- 'data-type': type
1015
- }
1016
- });
1017
-
1018
- const timestamp = DOMUtils.createElement('span', {
1019
- className: `${this.player.options.classPrefix}-transcript-time`,
1020
- textContent: TimeUtils.formatTime(cue.startTime),
1021
- attributes: {
1022
- 'aria-hidden': 'true' // Hide from screen readers - decorative timestamp
1023
- }
1024
- });
1025
-
1026
- const text = DOMUtils.createElement('span', {
1027
- className: `${this.player.options.classPrefix}-transcript-text`,
1028
- textContent: entryText
1029
- });
1030
-
1031
- entry.appendChild(timestamp);
1032
- entry.appendChild(text);
1033
-
1034
- // Click to seek
1035
- const seekToTime = () => {
1036
- this.player.seek(cue.startTime);
1037
- if (this.player.state.paused) {
1038
- this.player.play();
1039
- }
1040
- };
1041
-
1042
- entry.addEventListener('click', seekToTime);
1043
- entry.addEventListener('keydown', (e) => {
1044
- if (e.key === 'Enter' || e.key === ' ') {
1045
- e.preventDefault();
1046
- seekToTime();
1047
- }
1048
- });
1049
-
1050
- return entry;
1051
- }
1052
-
1053
- /**
1054
- * Strip VTT formatting tags from text
1055
- */
1056
- stripVTTFormatting(text) {
1057
- // Remove VTT tags like <v Speaker>, <c>, etc.
1058
- return text
1059
- .replace(/<[^>]+>/g, '')
1060
- .replace(/\n/g, ' ')
1061
- .trim();
1062
- }
1063
-
1064
- /**
1065
- * Show message when no transcript is available
1066
- */
1067
- showNoTranscriptMessage() {
1068
- const message = DOMUtils.createElement('div', {
1069
- className: `${this.player.options.classPrefix}-transcript-empty`,
1070
- textContent: i18n.t('transcript.noTranscript')
1071
- });
1072
- this.transcriptContent.appendChild(message);
1073
- }
1074
-
1075
- /**
1076
- * Update active transcript entry based on current time
1077
- */
1078
- updateActiveEntry() {
1079
- if (!this.isVisible || this.transcriptEntries.length === 0) return;
1080
-
1081
- const currentTime = this.player.state.currentTime;
1082
-
1083
- // Find the entry that matches current time
1084
- const activeEntry = this.transcriptEntries.find(
1085
- entry => currentTime >= entry.startTime && currentTime < entry.endTime
1086
- );
1087
-
1088
- if (activeEntry && activeEntry !== this.currentActiveEntry) {
1089
- // Remove previous active class
1090
- if (this.currentActiveEntry) {
1091
- this.currentActiveEntry.element.classList.remove(
1092
- `${this.player.options.classPrefix}-transcript-entry-active`
1093
- );
1094
- }
1095
-
1096
- // Add active class to current entry
1097
- activeEntry.element.classList.add(
1098
- `${this.player.options.classPrefix}-transcript-entry-active`
1099
- );
1100
-
1101
- // Scroll to active entry
1102
- this.scrollToEntry(activeEntry.element);
1103
-
1104
- this.currentActiveEntry = activeEntry;
1105
- } else if (!activeEntry && this.currentActiveEntry) {
1106
- // No active entry, remove active class
1107
- this.currentActiveEntry.element.classList.remove(
1108
- `${this.player.options.classPrefix}-transcript-entry-active`
1109
- );
1110
- this.currentActiveEntry = null;
1111
- }
1112
- }
1113
-
1114
- /**
1115
- * Scroll transcript window to show active entry
1116
- */
1117
- scrollToEntry(entryElement) {
1118
- if (!this.transcriptContent || !this.autoscrollEnabled) return;
1119
-
1120
- const contentRect = this.transcriptContent.getBoundingClientRect();
1121
- const entryRect = entryElement.getBoundingClientRect();
1122
-
1123
- // Check if entry is out of view
1124
- if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
1125
- // Scroll to center the entry
1126
- const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
1127
- this.transcriptContent.scrollTo({
1128
- top: scrollTop,
1129
- behavior: 'smooth'
1130
- });
1131
- }
1132
- }
1133
-
1134
- /**
1135
- * Save autoscroll preference to localStorage
1136
- */
1137
- saveAutoscrollPreference() {
1138
- const savedPreferences = this.storage.getTranscriptPreferences() || {};
1139
- savedPreferences.autoscroll = this.autoscrollEnabled;
1140
- this.storage.saveTranscriptPreferences(savedPreferences);
1141
- }
1142
-
1143
- /**
1144
- * Setup drag and drop functionality
1145
- */
1146
- setupDragAndDrop() {
1147
- if (!this.transcriptHeader || !this.transcriptWindow) return;
1148
-
1149
- // Check if we're on mobile and not in fullscreen
1150
- const isMobile = window.innerWidth < 768;
1151
- const isFullscreen = this.player.state.fullscreen;
1152
-
1153
- // On mobile devices (< 768px), only enable drag/resize in fullscreen
1154
- // On desktop/tablets (>= 768px), always enable drag/resize
1155
- if (isMobile && !isFullscreen) {
1156
- // Destroy existing instance if exiting fullscreen on mobile
1157
- if (this.draggableResizable) {
1158
- this.draggableResizable.destroy();
1159
- this.draggableResizable = null;
1160
- }
1161
- return; // No drag/resize on mobile when not in fullscreen
1162
- }
1163
-
1164
- // If already initialized, don't re-initialize
1165
- if (this.draggableResizable) {
1166
- return;
1167
- }
1168
-
1169
- // Create DraggableResizable utility with touch support
1170
- this.draggableResizable = new DraggableResizable(this.transcriptWindow, {
1171
- dragHandle: this.transcriptHeader,
1172
- resizeHandles: this.transcriptResizeHandles,
1173
- constrainToViewport: true,
1174
- classPrefix: `${this.player.options.classPrefix}-transcript`,
1175
- keyboardDragKey: 'd',
1176
- keyboardResizeKey: 'r',
1177
- keyboardStep: 10,
1178
- keyboardStepLarge: 50,
1179
- minWidth: 300,
1180
- minHeight: 200,
1181
- maxWidth: () => Math.max(320, window.innerWidth - 40),
1182
- maxHeight: () => Math.max(200, window.innerHeight - 120),
1183
- pointerResizeIndicatorText: i18n.t('transcript.resizeModeHint'),
1184
- onPointerResizeToggle: (enabled) => {
1185
- // Update resize handles visibility
1186
- this.transcriptResizeHandles.forEach(handle => {
1187
- handle.style.display = enabled ? 'block' : 'none';
1188
- });
1189
- // Call the state change handler
1190
- this.onPointerResizeModeChange(enabled);
1191
- },
1192
- onDragStart: (e) => {
1193
- // Don't drag if clicking on certain elements
1194
- const ignoreSelectors = [
1195
- `.${this.player.options.classPrefix}-transcript-close`,
1196
- `.${this.player.options.classPrefix}-transcript-settings`,
1197
- `.${this.player.options.classPrefix}-transcript-language-select`,
1198
- `.${this.player.options.classPrefix}-transcript-language-label`,
1199
- `.${this.player.options.classPrefix}-transcript-settings-menu`,
1200
- `.${this.player.options.classPrefix}-transcript-style-dialog`
1201
- ];
1202
-
1203
- for (const selector of ignoreSelectors) {
1204
- if (e.target.closest(selector)) {
1205
- return false; // Prevent drag
1206
- }
1207
- }
1208
-
1209
- return true; // Allow drag
1210
- }
1211
- });
1212
-
1213
- // Add custom keyboard handler for special keys (Escape, Home)
1214
- this.customKeyHandler = (e) => {
1215
- const key = e.key.toLowerCase();
1216
- const alreadyPrevented = e.defaultPrevented;
1217
-
1218
- // Don't handle keys if settings menu or style dialog is open (let them handle keys)
1219
- if (this.settingsMenuVisible || this.styleDialogVisible) {
1220
- return;
1221
- }
1222
-
1223
- if (key === 'home') {
1224
- e.preventDefault();
1225
- e.stopPropagation();
1226
- if (this.draggableResizable) {
1227
- if (this.draggableResizable.pointerResizeMode) {
1228
- this.draggableResizable.disablePointerResizeMode();
1229
- }
1230
- this.draggableResizable.manuallyPositioned = false;
1231
- this.positionTranscript();
1232
- this.updateResizeOptionState();
1233
- this.announceLive(i18n.t('transcript.positionReset'));
1234
- }
1235
- return;
1236
- }
1237
-
1238
- if (key === 'r') {
1239
- if (alreadyPrevented) {
1240
- return;
1241
- }
1242
- e.preventDefault();
1243
- e.stopPropagation();
1244
- const enabled = this.toggleResizeMode();
1245
- if (enabled) {
1246
- this.transcriptWindow.focus();
1247
- }
1248
- return;
1249
- }
1250
-
1251
- if (key === 'escape') {
1252
- // Check priority: resize mode > drag mode > close transcript
1253
- // (settings menu and style dialog already handled by early return above)
1254
- if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
1255
- e.preventDefault();
1256
- e.stopPropagation();
1257
- this.draggableResizable.disablePointerResizeMode();
1258
- return;
1259
- }
1260
- if (this.draggableResizable && this.draggableResizable.keyboardDragMode) {
1261
- e.preventDefault();
1262
- e.stopPropagation();
1263
- this.draggableResizable.disableKeyboardDragMode();
1264
- this.announceLive(i18n.t('transcript.dragModeDisabled'));
1265
- return;
1266
- }
1267
- // Only close transcript if nothing else is open
1268
- e.preventDefault();
1269
- e.stopPropagation();
1270
- this.hideTranscript({ focusButton: true });
1271
- return;
1272
- }
1273
- };
1274
-
1275
- this.transcriptWindow.addEventListener('keydown', this.customKeyHandler);
1276
- }
1277
-
1278
-
1279
- /**
1280
- * Toggle keyboard drag mode
1281
- */
1282
- toggleKeyboardDragMode() {
1283
- if (this.draggableResizable) {
1284
- const wasEnabled = this.draggableResizable.keyboardDragMode;
1285
- this.draggableResizable.toggleKeyboardDragMode();
1286
- const isEnabled = this.draggableResizable.keyboardDragMode;
1287
- if (!wasEnabled && isEnabled) {
1288
- this.enableMoveMode();
1289
- }
1290
-
1291
- // Update drag option state
1292
- this.updateDragOptionState();
1293
-
1294
- // Hide settings menu if open
1295
- if (this.settingsMenuVisible) {
1296
- this.hideSettingsMenu();
1297
- }
1298
-
1299
- // Focus the window for keyboard navigation
1300
- this.transcriptWindow.focus();
1301
- }
1302
- }
1303
-
1304
- /**
1305
- * Toggle settings menu visibility
1306
- */
1307
- toggleSettingsMenu() {
1308
- if (this.settingsMenuVisible) {
1309
- this.hideSettingsMenu();
1310
- } else {
1311
- this.showSettingsMenu();
1312
- }
1313
- }
1314
-
1315
- /**
1316
- * Show settings menu
1317
- */
1318
- showSettingsMenu() {
1319
- // Set flag to prevent immediate closing
1320
- this.settingsMenuJustOpened = true;
1321
- setTimeout(() => {
1322
- this.settingsMenuJustOpened = false;
1323
- }, 350);
1324
-
1325
- // Add document click handler on FIRST menu open (not at window creation)
1326
- if (!this.documentClickHandlerAdded) {
1327
- setTimeout(() => {
1328
- document.addEventListener('click', this.handlers.documentClick);
1329
- this.documentClickHandlerAdded = true;
1330
- }, 300);
1331
- }
1332
-
1333
- if (this.settingsMenu) {
1334
- this.settingsMenu.style.display = 'block';
1335
- this.settingsMenuVisible = true;
1336
- if (this.settingsButton) {
1337
- this.settingsButton.setAttribute('aria-expanded', 'true');
1338
- }
1339
- // Re-attach keyboard navigation handler
1340
- this.attachSettingsMenuKeyboardNavigation();
1341
- // Position menu immediately
1342
- this.positionSettingsMenuImmediate();
1343
- this.updateResizeOptionState();
1344
- // Focus first menu item after positioning
1345
- setTimeout(() => {
1346
- const menuItems = this.settingsMenu.querySelectorAll(`.${this.player.options.classPrefix}-transcript-settings-item`);
1347
- if (menuItems.length > 0) {
1348
- menuItems[0].setAttribute('tabindex', '0');
1349
- for (let i = 1; i < menuItems.length; i++) {
1350
- menuItems[i].setAttribute('tabindex', '-1');
1351
- }
1352
- menuItems[0].focus();
1353
- }
1354
- }, 50);
1355
- return;
1356
- }
1357
- // Create settings menu
1358
- this.settingsMenu = DOMUtils.createElement('div', {
1359
- className: `${this.player.options.classPrefix}-transcript-settings-menu`,
1360
- attributes: {
1361
- 'role': 'menu'
1362
- }
1363
- });
1364
-
1365
- // Keyboard drag option
1366
- const keyboardDragOption = createMenuItem({
1367
- classPrefix: this.player.options.classPrefix,
1368
- itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1369
- icon: 'move',
1370
- label: 'transcript.enableDragMode',
1371
- hasTextClass: true,
1372
- onClick: () => {
1373
- this.toggleKeyboardDragMode();
1374
- this.hideSettingsMenu();
1375
- }
1376
- });
1377
- keyboardDragOption.setAttribute('role', 'switch');
1378
- keyboardDragOption.setAttribute('aria-checked', 'false');
1379
- this.dragOptionButton = keyboardDragOption;
1380
- this.dragOptionText = keyboardDragOption.querySelector(`.${this.player.options.classPrefix}-settings-text`);
1381
- this.updateDragOptionState();
1382
-
1383
- // Style option
1384
- const styleOption = createMenuItem({
1385
- classPrefix: this.player.options.classPrefix,
1386
- itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1387
- icon: 'settings',
1388
- label: 'transcript.styleTranscript',
1389
- onClick: (e) => {
1390
- e.preventDefault();
1391
- e.stopPropagation();
1392
- this.hideSettingsMenu();
1393
- // Delay to ensure menu is fully closed before opening dialog
1394
- setTimeout(() => {
1395
- this.showStyleDialog();
1396
- }, 50);
1397
- }
1398
- });
1399
-
1400
- // Resize option
1401
- const resizeOption = createMenuItem({
1402
- classPrefix: this.player.options.classPrefix,
1403
- itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1404
- icon: 'resize',
1405
- label: 'transcript.enableResizeMode',
1406
- hasTextClass: true,
1407
- onClick: (event) => {
1408
- event.preventDefault();
1409
- event.stopPropagation();
1410
-
1411
- const enabled = this.toggleResizeMode({ focus: false });
1412
-
1413
- if (enabled) {
1414
- this.hideSettingsMenu({ focusButton: false });
1415
- // Focus transcript window after handles appear
1416
- setTimeout(() => {
1417
- if (this.transcriptWindow) {
1418
- this.transcriptWindow.focus();
1419
- }
1420
- }, 20);
1421
- } else {
1422
- this.hideSettingsMenu({ focusButton: true });
1423
- }
1424
- }
1425
- });
1426
- resizeOption.setAttribute('role', 'switch');
1427
- resizeOption.setAttribute('aria-checked', 'false');
1428
- this.resizeOptionButton = resizeOption;
1429
- this.resizeOptionText = resizeOption.querySelector(`.${this.player.options.classPrefix}-settings-text`);
1430
- this.updateResizeOptionState();
1431
-
1432
- // Show timestamps option
1433
- const showTimestampsOption = createMenuItem({
1434
- classPrefix: this.player.options.classPrefix,
1435
- itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1436
- icon: 'clock',
1437
- label: 'transcript.showTimestamps',
1438
- hasTextClass: true,
1439
- onClick: () => {
1440
- this.toggleShowTimestamps();
1441
- }
1442
- });
1443
- showTimestampsOption.setAttribute('role', 'switch');
1444
- showTimestampsOption.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
1445
- this.showTimestampsButton = showTimestampsOption;
1446
- this.showTimestampsText = showTimestampsOption.querySelector(`.${this.player.options.classPrefix}-settings-text`);
1447
- this.updateShowTimestampsState();
1448
-
1449
- // Close option
1450
- const closeOption = createMenuItem({
1451
- classPrefix: this.player.options.classPrefix,
1452
- itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1453
- icon: 'close',
1454
- label: 'transcript.closeMenu',
1455
- onClick: () => {
1456
- this.hideSettingsMenu();
1457
- }
1458
- });
1459
-
1460
- this.settingsMenu.appendChild(keyboardDragOption);
1461
- this.settingsMenu.appendChild(resizeOption);
1462
- this.settingsMenu.appendChild(styleOption);
1463
- this.settingsMenu.appendChild(showTimestampsOption);
1464
- this.settingsMenu.appendChild(closeOption);
1465
-
1466
- // Position menu first (before it's visible) to prevent jumping
1467
- // Set menu to invisible temporarily
1468
- this.settingsMenu.style.visibility = 'hidden';
1469
- this.settingsMenu.style.display = 'block';
1470
-
1471
- // Insert menu right after settings button for proper positioning
1472
- if (this.settingsButton && this.settingsButton.parentNode) {
1473
- this.settingsButton.insertAdjacentElement('afterend', this.settingsMenu);
1474
- } else if (this.headerLeft) {
1475
- this.headerLeft.appendChild(this.settingsMenu);
1476
- } else if (this.transcriptHeader) {
1477
- this.transcriptHeader.appendChild(this.settingsMenu);
1478
- } else {
1479
- this.transcriptWindow.appendChild(this.settingsMenu);
1480
- }
1481
-
1482
- // Position the menu relative to the settings button (immediately while hidden)
1483
- this.positionSettingsMenuImmediate();
1484
-
1485
- // Make menu visible after positioning
1486
- requestAnimationFrame(() => {
1487
- if (this.settingsMenu) {
1488
- this.settingsMenu.style.visibility = 'visible';
1489
- }
1490
- });
1491
-
1492
- // Add keyboard navigation
1493
- this.settingsMenuKeyHandler = attachMenuKeyboardNavigation(
1494
- this.settingsMenu,
1495
- this.settingsButton,
1496
- `.${this.player.options.classPrefix}-transcript-settings-item`,
1497
- () => this.hideSettingsMenu({ focusButton: true })
1498
- );
1499
-
1500
- // Set the menu as visible and display it
1501
- this.settingsMenuVisible = true;
1502
- this.settingsMenu.style.display = 'block';
1503
-
1504
- // Update aria-expanded
1505
- if (this.settingsButton) {
1506
- this.settingsButton.setAttribute('aria-expanded', 'true');
1507
- }
1508
- this.updateResizeOptionState();
1509
-
1510
- // Focus first menu item after visibility is set
1511
- setTimeout(() => {
1512
- const menuItems = this.settingsMenu.querySelectorAll(`.${this.player.options.classPrefix}-transcript-settings-item`);
1513
- if (menuItems.length > 0) {
1514
- menuItems[0].setAttribute('tabindex', '0');
1515
- for (let i = 1; i < menuItems.length; i++) {
1516
- menuItems[i].setAttribute('tabindex', '-1');
1517
- }
1518
- menuItems[0].focus();
1519
- }
1520
- }, 50);
1521
- }
1522
-
1523
- /**
1524
- * Position settings menu relative to settings button (immediate/synchronous)
1525
- */
1526
- positionSettingsMenuImmediate() {
1527
- if (!this.settingsMenu || !this.settingsButton) return;
1528
-
1529
- // Get the parent container (header-left) which has position: relative
1530
- const container = this.settingsButton.parentElement;
1531
- if (!container) return;
1532
-
1533
- // Position immediately (synchronously) - used when menu is first shown
1534
- const buttonRect = this.settingsButton.getBoundingClientRect();
1535
- const containerRect = container.getBoundingClientRect();
1536
- const menuRect = this.settingsMenu.getBoundingClientRect();
1537
- const viewportHeight = window.innerHeight;
1538
-
1539
- // Calculate position relative to the container
1540
- const buttonLeft = buttonRect.left - containerRect.left;
1541
- const buttonBottom = buttonRect.bottom - containerRect.top;
1542
- const buttonTop = buttonRect.top - containerRect.top;
1543
-
1544
- const spaceBelow = viewportHeight - buttonRect.bottom;
1545
- const spaceAbove = buttonRect.top;
1546
-
1547
- // Position menu below button by default (left-aligned with button)
1548
- let menuTop = buttonBottom + 4;
1549
-
1550
- // Check if we should position above instead
1551
- if (spaceBelow < menuRect.height + 20 && spaceAbove > spaceBelow) {
1552
- // Position above the button
1553
- menuTop = buttonTop - menuRect.height - 4;
1554
- this.settingsMenu.classList.add('vidply-menu-above');
1555
- } else {
1556
- this.settingsMenu.classList.remove('vidply-menu-above');
1557
- }
1558
-
1559
- // Apply positions (left-aligned with button)
1560
- this.settingsMenu.style.top = `${menuTop}px`;
1561
- this.settingsMenu.style.left = `${buttonLeft}px`;
1562
- this.settingsMenu.style.right = 'auto';
1563
- this.settingsMenu.style.bottom = 'auto';
1564
- }
1565
-
1566
- /**
1567
- * Position settings menu relative to settings button (async for repositioning)
1568
- */
1569
- positionSettingsMenu() {
1570
- if (!this.settingsMenu || !this.settingsButton) return;
1571
-
1572
- // Use requestAnimationFrame to ensure layout is stable before positioning (for repositioning)
1573
- requestAnimationFrame(() => {
1574
- setTimeout(() => {
1575
- this.positionSettingsMenuImmediate();
1576
- }, 10); // Small delay to ensure layout is stable
1577
- });
1578
- }
1579
-
1580
- /**
1581
- * Attach keyboard navigation to settings menu
1582
- */
1583
- attachSettingsMenuKeyboardNavigation() {
1584
- if (!this.settingsMenu) return;
1585
-
1586
- // Remove existing handler if any
1587
- if (this.settingsMenuKeyHandler) {
1588
- this.settingsMenu.removeEventListener('keydown', this.settingsMenuKeyHandler, true);
1589
- }
1590
-
1591
- const handler = attachMenuKeyboardNavigation(
1592
- this.settingsMenu,
1593
- this.settingsButton,
1594
- `.${this.player.options.classPrefix}-transcript-settings-item`,
1595
- () => this.hideSettingsMenu({ focusButton: true })
1596
- );
1597
-
1598
- // Store the handler reference
1599
- this.settingsMenuKeyHandler = handler;
1600
-
1601
- }
1602
-
1603
- /**
1604
- * Hide settings menu
1605
- */
1606
- hideSettingsMenu({ focusButton = true } = {}) {
1607
- if (this.settingsMenu) {
1608
- this.settingsMenu.style.display = 'none';
1609
- this.settingsMenuVisible = false;
1610
- this.settingsMenuJustOpened = false;
1611
-
1612
- // Remove keyboard handler to prevent duplicate listeners
1613
- if (this.settingsMenuKeyHandler) {
1614
- this.settingsMenu.removeEventListener('keydown', this.settingsMenuKeyHandler, true);
1615
- this.settingsMenuKeyHandler = null;
1616
- }
1617
-
1618
- // Update aria-expanded
1619
- if (this.settingsButton) {
1620
- this.settingsButton.setAttribute('aria-expanded', 'false');
1621
- if (focusButton) {
1622
- // Return focus to settings button
1623
- this.settingsButton.focus();
1624
- }
1625
- }
1626
- }
1627
- }
1628
-
1629
- /**
1630
- * Enable move mode (gives visual feedback)
1631
- */
1632
- enableMoveMode() {
1633
- this.hideResizeModeIndicator();
1634
-
1635
- // Add visual feedback for move mode
1636
- this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-move-mode`);
1637
-
1638
- // Show tooltip about keyboard drag option
1639
- const tooltip = DOMUtils.createElement('div', {
1640
- className: `${this.player.options.classPrefix}-transcript-move-tooltip`,
1641
- textContent: 'Drag with mouse or press D for keyboard drag mode'
1642
- });
1643
- this.transcriptHeader.appendChild(tooltip);
1644
-
1645
- // Remove after 2 seconds
1646
- setTimeout(() => {
1647
- this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-move-mode`);
1648
- if (tooltip.parentNode) {
1649
- tooltip.remove();
1650
- }
1651
- }, 2000);
1652
- }
1653
-
1654
- /**
1655
- * Toggle resize mode
1656
- */
1657
- toggleResizeMode({ focus = true } = {}) {
1658
- if (!this.draggableResizable) {
1659
- return false;
1660
- }
1661
-
1662
- if (this.draggableResizable.pointerResizeMode) {
1663
- this.draggableResizable.disablePointerResizeMode({ focus });
1664
- return false;
1665
- }
1666
-
1667
- this.draggableResizable.enablePointerResizeMode({ focus });
1668
- return true;
1669
- }
1670
-
1671
- updateDragOptionState() {
1672
- if (!this.dragOptionButton) {
1673
- return;
1674
- }
1675
-
1676
- const isEnabled = !!(this.draggableResizable && this.draggableResizable.keyboardDragMode);
1677
- const text = isEnabled
1678
- ? i18n.t('transcript.disableDragMode')
1679
- : i18n.t('transcript.enableDragMode');
1680
- const ariaLabel = isEnabled
1681
- ? i18n.t('transcript.disableDragModeAria')
1682
- : i18n.t('transcript.enableDragModeAria');
1683
-
1684
- this.dragOptionButton.setAttribute('aria-checked', isEnabled ? 'true' : 'false');
1685
- this.dragOptionButton.setAttribute('aria-label', ariaLabel);
1686
- this.dragOptionButton.setAttribute('title', text);
1687
-
1688
- if (this.dragOptionText) {
1689
- this.dragOptionText.textContent = text;
1690
- }
1691
- }
1692
-
1693
- updateResizeOptionState() {
1694
- if (!this.resizeOptionButton) {
1695
- return;
1696
- }
1697
-
1698
- const isEnabled = !!(this.draggableResizable && this.draggableResizable.pointerResizeMode);
1699
- const text = isEnabled
1700
- ? i18n.t('transcript.disableResizeMode')
1701
- : i18n.t('transcript.enableResizeMode');
1702
- const ariaLabel = isEnabled
1703
- ? i18n.t('transcript.disableResizeModeAria')
1704
- : i18n.t('transcript.enableResizeModeAria');
1705
-
1706
- this.resizeOptionButton.setAttribute('aria-checked', isEnabled ? 'true' : 'false');
1707
- this.resizeOptionButton.setAttribute('aria-label', ariaLabel);
1708
- this.resizeOptionButton.setAttribute('title', text);
1709
-
1710
- if (this.resizeOptionText) {
1711
- this.resizeOptionText.textContent = text;
1712
- }
1713
- }
1714
-
1715
- toggleShowTimestamps() {
1716
- this.showTimestamps = !this.showTimestamps;
1717
- this.updateShowTimestampsState();
1718
- this.updateTimestampVisibility();
1719
- this.saveTimestampsPreference();
1720
- }
1721
-
1722
- updateShowTimestampsState() {
1723
- if (!this.showTimestampsButton) {
1724
- return;
1725
- }
1726
-
1727
- const text = this.showTimestamps
1728
- ? i18n.t('transcript.hideTimestamps')
1729
- : i18n.t('transcript.showTimestamps');
1730
- const ariaLabel = this.showTimestamps
1731
- ? i18n.t('transcript.hideTimestampsAria')
1732
- : i18n.t('transcript.showTimestampsAria');
1733
-
1734
- this.showTimestampsButton.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
1735
- this.showTimestampsButton.setAttribute('aria-label', ariaLabel);
1736
- this.showTimestampsButton.setAttribute('title', text);
1737
-
1738
- if (this.showTimestampsText) {
1739
- this.showTimestampsText.textContent = text;
1740
- }
1741
- }
1742
-
1743
- updateTimestampVisibility() {
1744
- if (!this.transcriptContent) return;
1745
-
1746
- const timestamps = this.transcriptContent.querySelectorAll(`.${this.player.options.classPrefix}-transcript-time`);
1747
- timestamps.forEach(timestamp => {
1748
- timestamp.style.display = this.showTimestamps ? '' : 'none';
1749
- });
1750
- }
1751
-
1752
- saveTimestampsPreference() {
1753
- const savedPreferences = this.storage.getTranscriptPreferences() || {};
1754
- savedPreferences.showTimestamps = this.showTimestamps;
1755
- this.storage.saveTranscriptPreferences(savedPreferences);
1756
- }
1757
-
1758
- showResizeModeIndicator() {
1759
- if (!this.transcriptHeader) {
1760
- return;
1761
- }
1762
-
1763
- this.hideResizeModeIndicator();
1764
-
1765
- const indicator = DOMUtils.createElement('div', {
1766
- className: `${this.player.options.classPrefix}-transcript-resize-tooltip`,
1767
- textContent: i18n.t('transcript.resizeModeHint') || 'Resize handles enabled. Drag edges or corners to adjust. Press Esc or R to exit.'
1768
- });
1769
-
1770
- this.transcriptHeader.appendChild(indicator);
1771
- this.resizeModeIndicator = indicator;
1772
-
1773
- this.resizeModeIndicatorTimeout = this.setManagedTimeout(() => {
1774
- this.hideResizeModeIndicator();
1775
- }, 3000);
1776
- }
1777
-
1778
- hideResizeModeIndicator() {
1779
- if (this.resizeModeIndicatorTimeout) {
1780
- this.clearManagedTimeout(this.resizeModeIndicatorTimeout);
1781
- this.resizeModeIndicatorTimeout = null;
1782
- }
1783
-
1784
- if (this.resizeModeIndicator && this.resizeModeIndicator.parentNode) {
1785
- this.resizeModeIndicator.remove();
1786
- }
1787
-
1788
- this.resizeModeIndicator = null;
1789
- }
1790
-
1791
- onPointerResizeModeChange(enabled) {
1792
- this.updateResizeOptionState();
1793
-
1794
- if (enabled) {
1795
- this.showResizeModeIndicator();
1796
- this.announceLive(i18n.t('transcript.resizeModeEnabled'));
1797
- } else {
1798
- this.hideResizeModeIndicator();
1799
- this.announceLive(i18n.t('transcript.resizeModeDisabled'));
1800
- }
1801
- }
1802
-
1803
- /**
1804
- * Show style dialog
1805
- */
1806
- showStyleDialog() {
1807
- // If dialog already exists, just show it
1808
- if (this.styleDialog) {
1809
- this.styleDialog.style.display = 'block';
1810
- this.styleDialogVisible = true;
1811
-
1812
- // Re-add keyboard handler
1813
- if (this.handlers.styleDialogKeydown) {
1814
- document.addEventListener('keydown', this.handlers.styleDialogKeydown);
1815
- }
1816
-
1817
- // Set flag to prevent immediate closing from document click
1818
- this.styleDialogJustOpened = true;
1819
- setTimeout(() => {
1820
- this.styleDialogJustOpened = false;
1821
- }, 350);
1822
-
1823
- // Focus first control
1824
- setTimeout(() => {
1825
- const firstSelect = this.styleDialog.querySelector('select, input');
1826
- if (firstSelect) {
1827
- firstSelect.focus();
1828
- }
1829
- }, 0);
1830
- return;
1831
- }
1832
-
1833
- // Create style dialog
1834
- this.styleDialog = DOMUtils.createElement('div', {
1835
- className: `${this.player.options.classPrefix}-transcript-style-dialog`
1836
- });
1837
-
1838
- // Dialog title
1839
- const title = DOMUtils.createElement('h4', {
1840
- textContent: i18n.t('transcript.styleTitle'),
1841
- className: `${this.player.options.classPrefix}-transcript-style-title`
1842
- });
1843
- this.styleDialog.appendChild(title);
1844
-
1845
- // Font Size
1846
- const fontSizeControl = this.createStyleSelectControl(
1847
- i18n.t('captions.fontSize'),
1848
- 'fontSize',
1849
- [
1850
- { label: i18n.t('fontSizes.small'), value: '90%' },
1851
- { label: i18n.t('fontSizes.normal'), value: '100%' },
1852
- { label: i18n.t('fontSizes.large'), value: '110%' },
1853
- { label: i18n.t('fontSizes.xlarge'), value: '120%' }
1854
- ]
1855
- );
1856
- this.styleDialog.appendChild(fontSizeControl);
1857
-
1858
- // Font Family
1859
- const fontFamilyControl = this.createStyleSelectControl(
1860
- i18n.t('captions.fontFamily'),
1861
- 'fontFamily',
1862
- [
1863
- { label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif' },
1864
- { label: i18n.t('fontFamilies.serif'), value: 'serif' },
1865
- { label: i18n.t('fontFamilies.monospace'), value: 'monospace' }
1866
- ]
1867
- );
1868
- this.styleDialog.appendChild(fontFamilyControl);
1869
-
1870
- // Text Color
1871
- const colorControl = this.createStyleColorControl(i18n.t('captions.color'), 'color');
1872
- this.styleDialog.appendChild(colorControl);
1873
-
1874
- // Background Color
1875
- const bgColorControl = this.createStyleColorControl(i18n.t('captions.backgroundColor'), 'backgroundColor');
1876
- this.styleDialog.appendChild(bgColorControl);
1877
-
1878
- // Opacity
1879
- const opacityControl = this.createStyleOpacityControl(i18n.t('captions.opacity'), 'opacity');
1880
- this.styleDialog.appendChild(opacityControl);
1881
-
1882
- // Close button
1883
- const closeBtn = DOMUtils.createElement('button', {
1884
- className: `${this.player.options.classPrefix}-transcript-style-close`,
1885
- textContent: i18n.t('settings.close'),
1886
- attributes: {
1887
- 'type': 'button'
1888
- }
1889
- });
1890
- closeBtn.addEventListener('click', () => this.hideStyleDialog());
1891
- this.styleDialog.appendChild(closeBtn);
1892
-
1893
- // Keyboard navigation for style dialog
1894
- this.handlers.styleDialogKeydown = (e) => {
1895
- // Only handle keys when dialog is visible
1896
- if (!this.styleDialogVisible) return;
1897
-
1898
- // ESC to close
1899
- if (e.key === 'Escape') {
1900
- e.preventDefault();
1901
- e.stopPropagation();
1902
- this.hideStyleDialog();
1903
- return;
1904
- }
1905
-
1906
- // Tab navigation (allow default behavior but trap focus)
1907
- if (e.key === 'Tab') {
1908
- // Get all focusable elements
1909
- const focusableElements = this.styleDialog.querySelectorAll(
1910
- 'select, input, button'
1911
- );
1912
- const firstElement = focusableElements[0];
1913
- const lastElement = focusableElements[focusableElements.length - 1];
1914
-
1915
- // Trap focus within dialog
1916
- if (e.shiftKey && document.activeElement === firstElement) {
1917
- e.preventDefault();
1918
- lastElement.focus();
1919
- } else if (!e.shiftKey && document.activeElement === lastElement) {
1920
- e.preventDefault();
1921
- firstElement.focus();
1922
- }
1923
- }
1924
- };
1925
- document.addEventListener('keydown', this.handlers.styleDialogKeydown);
1926
-
1927
- // Append to header left container (same as settings menu) for correct positioning
1928
- if (this.headerLeft) {
1929
- this.headerLeft.appendChild(this.styleDialog);
1930
- } else {
1931
- this.transcriptHeader.appendChild(this.styleDialog);
1932
- }
1933
-
1934
- // Apply current styles
1935
- this.applyTranscriptStyles();
1936
-
1937
- // Important: Set visible state and display before focusing
1938
- this.styleDialogVisible = true;
1939
- this.styleDialog.style.display = 'block';
1940
-
1941
- // Set flag to prevent immediate closing from document click
1942
- this.styleDialogJustOpened = true;
1943
- setTimeout(() => {
1944
- this.styleDialogJustOpened = false;
1945
- }, 350);
1946
-
1947
- // Focus first control for keyboard accessibility
1948
- setTimeout(() => {
1949
- const firstSelect = this.styleDialog.querySelector('select, input');
1950
- if (firstSelect) {
1951
- firstSelect.focus();
1952
- }
1953
- }, 0);
1954
- }
1955
-
1956
- /**
1957
- * Hide style dialog
1958
- */
1959
- hideStyleDialog() {
1960
- if (this.styleDialog) {
1961
- this.styleDialog.style.display = 'none';
1962
- this.styleDialogVisible = false;
1963
-
1964
- // Remove keyboard handler
1965
- if (this.handlers.styleDialogKeydown) {
1966
- document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
1967
- }
1968
-
1969
- // Return focus to settings button
1970
- if (this.settingsButton) {
1971
- this.settingsButton.focus();
1972
- }
1973
- }
1974
- }
1975
-
1976
- /**
1977
- * Create style select control
1978
- */
1979
- createStyleSelectControl(label, property, options) {
1980
- const group = DOMUtils.createElement('div', {
1981
- className: `${this.player.options.classPrefix}-transcript-style-group`
1982
- });
1983
-
1984
- // Generate unique ID for the control
1985
- const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
1986
-
1987
- const labelEl = DOMUtils.createElement('label', {
1988
- textContent: label,
1989
- attributes: {
1990
- 'for': controlId
1991
- }
1992
- });
1993
- group.appendChild(labelEl);
1994
-
1995
- const select = DOMUtils.createElement('select', {
1996
- className: `${this.player.options.classPrefix}-transcript-style-select`,
1997
- attributes: {
1998
- 'id': controlId
1999
- }
2000
- });
2001
-
2002
- options.forEach(opt => {
2003
- const option = DOMUtils.createElement('option', {
2004
- textContent: opt.label,
2005
- attributes: {
2006
- 'value': opt.value
2007
- }
2008
- });
2009
- if (this.transcriptStyle[property] === opt.value) {
2010
- option.selected = true;
2011
- }
2012
- select.appendChild(option);
2013
- });
2014
-
2015
- select.addEventListener('change', (e) => {
2016
- this.transcriptStyle[property] = e.target.value;
2017
- this.applyTranscriptStyles();
2018
- this.savePreferences();
2019
- });
2020
-
2021
- group.appendChild(select);
2022
- return group;
2023
- }
2024
-
2025
- /**
2026
- * Create style color control
2027
- */
2028
- createStyleColorControl(label, property) {
2029
- const group = DOMUtils.createElement('div', {
2030
- className: `${this.player.options.classPrefix}-transcript-style-group`
2031
- });
2032
-
2033
- // Generate unique ID for the control
2034
- const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2035
-
2036
- const labelEl = DOMUtils.createElement('label', {
2037
- textContent: label,
2038
- attributes: {
2039
- 'for': controlId
2040
- }
2041
- });
2042
- group.appendChild(labelEl);
2043
-
2044
- const input = DOMUtils.createElement('input', {
2045
- attributes: {
2046
- 'id': controlId,
2047
- 'type': 'color',
2048
- 'value': this.transcriptStyle[property]
2049
- },
2050
- className: `${this.player.options.classPrefix}-transcript-style-color`
2051
- });
2052
-
2053
- input.addEventListener('input', (e) => {
2054
- this.transcriptStyle[property] = e.target.value;
2055
- this.applyTranscriptStyles();
2056
- this.savePreferences();
2057
- });
2058
-
2059
- group.appendChild(input);
2060
- return group;
2061
- }
2062
-
2063
- /**
2064
- * Create style opacity control
2065
- */
2066
- createStyleOpacityControl(label, property) {
2067
- const group = DOMUtils.createElement('div', {
2068
- className: `${this.player.options.classPrefix}-transcript-style-group`
2069
- });
2070
-
2071
- // Generate unique ID for the control
2072
- const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2073
-
2074
- const labelEl = DOMUtils.createElement('label', {
2075
- textContent: label,
2076
- attributes: {
2077
- 'for': controlId
2078
- }
2079
- });
2080
- group.appendChild(labelEl);
2081
-
2082
- const valueDisplay = DOMUtils.createElement('span', {
2083
- textContent: Math.round(this.transcriptStyle[property] * 100) + '%',
2084
- className: `${this.player.options.classPrefix}-transcript-style-value`
2085
- });
2086
-
2087
- const input = DOMUtils.createElement('input', {
2088
- attributes: {
2089
- 'id': controlId,
2090
- 'type': 'range',
2091
- 'min': '0',
2092
- 'max': '1',
2093
- 'step': '0.1',
2094
- 'value': String(this.transcriptStyle[property])
2095
- },
2096
- className: `${this.player.options.classPrefix}-transcript-style-range`
2097
- });
2098
-
2099
- input.addEventListener('input', (e) => {
2100
- const value = parseFloat(e.target.value);
2101
- this.transcriptStyle[property] = value;
2102
- valueDisplay.textContent = Math.round(value * 100) + '%';
2103
- this.applyTranscriptStyles();
2104
- this.savePreferences();
2105
- });
2106
-
2107
- const inputContainer = DOMUtils.createElement('div', {
2108
- className: `${this.player.options.classPrefix}-transcript-style-range-container`
2109
- });
2110
- inputContainer.appendChild(input);
2111
- inputContainer.appendChild(valueDisplay);
2112
-
2113
- group.appendChild(labelEl);
2114
- group.appendChild(inputContainer);
2115
- return group;
2116
- }
2117
-
2118
- /**
2119
- * Save transcript preferences to localStorage
2120
- */
2121
- savePreferences() {
2122
- this.storage.saveTranscriptPreferences(this.transcriptStyle);
2123
- }
2124
-
2125
- /**
2126
- * Apply transcript styles
2127
- */
2128
- applyTranscriptStyles() {
2129
- if (!this.transcriptWindow) return;
2130
-
2131
- // Apply to transcript window background
2132
- this.transcriptWindow.style.backgroundColor = this.transcriptStyle.backgroundColor;
2133
- this.transcriptWindow.style.opacity = String(this.transcriptStyle.opacity);
2134
-
2135
- // Apply to content area
2136
- if (this.transcriptContent) {
2137
- this.transcriptContent.style.fontSize = this.transcriptStyle.fontSize;
2138
- this.transcriptContent.style.fontFamily = this.transcriptStyle.fontFamily;
2139
- this.transcriptContent.style.color = this.transcriptStyle.color;
2140
- }
2141
-
2142
- // Apply to all text entries (important: override CSS defaults)
2143
- const textEntries = this.transcriptWindow.querySelectorAll(`.${this.player.options.classPrefix}-transcript-text`);
2144
- textEntries.forEach(entry => {
2145
- entry.style.fontSize = this.transcriptStyle.fontSize;
2146
- entry.style.fontFamily = this.transcriptStyle.fontFamily;
2147
- entry.style.color = this.transcriptStyle.color;
2148
- });
2149
-
2150
- // Apply to timestamp entries as well
2151
- const timeEntries = this.transcriptWindow.querySelectorAll(`.${this.player.options.classPrefix}-transcript-time`);
2152
- timeEntries.forEach(entry => {
2153
- entry.style.fontFamily = this.transcriptStyle.fontFamily;
2154
- });
2155
- }
2156
-
2157
- /**
2158
- * Set a managed timeout that will be cleaned up on destroy
2159
- * @param {Function} callback - Callback function
2160
- * @param {number} delay - Delay in milliseconds
2161
- * @returns {number} Timeout ID
2162
- */
2163
- setManagedTimeout(callback, delay) {
2164
- const timeoutId = setTimeout(() => {
2165
- this.timeouts.delete(timeoutId);
2166
- callback();
2167
- }, delay);
2168
- this.timeouts.add(timeoutId);
2169
- return timeoutId;
2170
- }
2171
-
2172
- /**
2173
- * Clear a managed timeout
2174
- * @param {number} timeoutId - Timeout ID to clear
2175
- */
2176
- clearManagedTimeout(timeoutId) {
2177
- if (timeoutId) {
2178
- clearTimeout(timeoutId);
2179
- this.timeouts.delete(timeoutId);
2180
- }
2181
- }
2182
-
2183
- /**
2184
- * Cleanup
2185
- */
2186
- destroy() {
2187
- this.hideResizeModeIndicator();
2188
-
2189
- // Destroy draggableResizable utility
2190
- if (this.draggableResizable) {
2191
- if (this.draggableResizable.pointerResizeMode) {
2192
- this.draggableResizable.disablePointerResizeMode();
2193
- this.updateResizeOptionState();
2194
- }
2195
- this.draggableResizable.destroy();
2196
- this.draggableResizable = null;
2197
- }
2198
-
2199
- // Remove custom key handler
2200
- if (this.transcriptWindow && this.customKeyHandler) {
2201
- this.transcriptWindow.removeEventListener('keydown', this.customKeyHandler);
2202
- this.customKeyHandler = null;
2203
- }
2204
-
2205
- // Remove timeupdate listener from player
2206
- if (this.handlers.timeupdate) {
2207
- this.player.off('timeupdate', this.handlers.timeupdate);
2208
- }
2209
-
2210
- // Remove audio description listeners from player
2211
- if (this.handlers.audiodescriptionenabled) {
2212
- this.player.off('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
2213
- }
2214
- if (this.handlers.audiodescriptiondisabled) {
2215
- this.player.off('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
2216
- }
2217
-
2218
- // Remove settings button event listeners
2219
- if (this.settingsButton) {
2220
- if (this.handlers.settingsClick) {
2221
- this.settingsButton.removeEventListener('click', this.handlers.settingsClick);
2222
- }
2223
- if (this.handlers.settingsKeydown) {
2224
- this.settingsButton.removeEventListener('keydown', this.handlers.settingsKeydown);
2225
- }
2226
- }
2227
-
2228
- // Remove style dialog event listeners
2229
- if (this.handlers.styleDialogKeydown) {
2230
- document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
2231
- }
2232
-
2233
- // Remove document click listener
2234
- if (this.handlers.documentClick) {
2235
- document.removeEventListener('click', this.handlers.documentClick);
2236
- }
2237
-
2238
- // Remove window-level listeners
2239
- if (this.handlers.resize) {
2240
- window.removeEventListener('resize', this.handlers.resize);
2241
- }
2242
-
2243
- // Cleanup all managed timeouts
2244
- this.timeouts.forEach(timeoutId => clearTimeout(timeoutId));
2245
- this.timeouts.clear();
2246
-
2247
- // Clear handlers
2248
- this.handlers = null;
2249
-
2250
- // Remove DOM element
2251
- if (this.transcriptWindow && this.transcriptWindow.parentNode) {
2252
- this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
2253
- }
2254
-
2255
- this.transcriptWindow = null;
2256
- this.transcriptHeader = null;
2257
- this.transcriptContent = null;
2258
- this.transcriptEntries = [];
2259
- this.settingsMenu = null;
2260
- this.styleDialog = null;
2261
- this.transcriptResizeHandles = [];
2262
- this.resizeOptionButton = null;
2263
- this.resizeOptionText = null;
2264
- this.liveRegion = null;
2265
- }
2266
-
2267
- announceLive(message) {
2268
- if (!this.liveRegion) return;
2269
- this.liveRegion.textContent = message || '';
2270
- }
2271
- }
1
+ /**
2
+ * Transcript Manager Component
3
+ * Manages transcript display and interaction
4
+ */
5
+
6
+ import { DOMUtils } from '../utils/DOMUtils.js';
7
+ import { TimeUtils } from '../utils/TimeUtils.js';
8
+ import { createIconElement } from '../icons/Icons.js';
9
+ import { i18n } from '../i18n/i18n.js';
10
+ import { StorageManager } from '../utils/StorageManager.js';
11
+ import { focusElement, focusFirstElement } from '../utils/FocusUtils.js';
12
+ import { createMenuItem, attachMenuKeyboardNavigation, focusFirstMenuItem } from '../utils/MenuUtils.js';
13
+ import { DraggableResizable } from '../utils/DraggableResizable.js';
14
+ import { createLabeledSelect, toggleLabeledSelect, preventDragOnElement } from '../utils/FormUtils.js';
15
+
16
+ export class TranscriptManager {
17
+ constructor(player) {
18
+ this.player = player;
19
+ this.transcriptWindow = null;
20
+ this.transcriptEntries = [];
21
+ this.metadataCues = [];
22
+ this.currentActiveEntry = null;
23
+ this.isVisible = false;
24
+
25
+ // Storage manager
26
+ this.storage = new StorageManager('vidply');
27
+
28
+ // Draggable/Resizable utility
29
+ this.draggableResizable = null;
30
+
31
+ // Settings menu state
32
+ this.settingsMenuVisible = false;
33
+ this.settingsMenu = null;
34
+ this.settingsButton = null;
35
+ this.settingsMenuJustOpened = false;
36
+
37
+ // Resize mode state
38
+ this.resizeOptionButton = null;
39
+ this.resizeOptionText = null;
40
+ this.dragOptionButton = null;
41
+ this.dragOptionText = null;
42
+ this.resizeModeIndicator = null;
43
+ this.resizeModeIndicatorTimeout = null;
44
+ this.transcriptResizeHandles = [];
45
+ this.liveRegion = null;
46
+
47
+ // Style dialog state
48
+ this.styleDialog = null;
49
+ this.styleDialogVisible = false;
50
+ this.styleDialogJustOpened = false;
51
+
52
+ // Language selector state
53
+ this.languageSelector = null;
54
+ this.languageLabel = null;
55
+ this.currentTranscriptLanguage = null;
56
+ this.availableTranscriptLanguages = [];
57
+ this.languageSelectorHandler = null;
58
+
59
+ // Load saved preferences from localStorage
60
+ const savedPreferences = this.storage.getTranscriptPreferences();
61
+
62
+ // Autoscroll state (default: true)
63
+ this.autoscrollEnabled = savedPreferences?.autoscroll !== undefined ? savedPreferences.autoscroll : true;
64
+
65
+ // Show timestamps state (default: false)
66
+ this.showTimestamps = savedPreferences?.showTimestamps !== undefined ? savedPreferences.showTimestamps : false;
67
+
68
+ // Transcript styling options (with defaults, then player options, then saved preferences)
69
+ this.transcriptStyle = {
70
+ fontSize: savedPreferences?.fontSize || this.player.options.transcriptFontSize || '100%',
71
+ fontFamily: savedPreferences?.fontFamily || this.player.options.transcriptFontFamily || 'sans-serif',
72
+ color: savedPreferences?.color || this.player.options.transcriptColor || '#ffffff',
73
+ backgroundColor: savedPreferences?.backgroundColor || this.player.options.transcriptBackgroundColor || '#1e1e1e',
74
+ opacity: savedPreferences?.opacity ?? this.player.options.transcriptOpacity ?? 0.98
75
+ };
76
+
77
+ // Store event handlers for cleanup
78
+ this.handlers = {
79
+ timeupdate: () => this.updateActiveEntry(),
80
+ audiodescriptionenabled: () => {
81
+ if (this.isVisible) {
82
+ this.loadTranscriptData();
83
+ }
84
+ },
85
+ audiodescriptiondisabled: () => {
86
+ if (this.isVisible) {
87
+ this.loadTranscriptData();
88
+ }
89
+ },
90
+ resize: null,
91
+ settingsClick: null,
92
+ settingsKeydown: null,
93
+ documentClick: null,
94
+ styleDialogKeydown: null
95
+ };
96
+
97
+ // Timeout management (for cleanup)
98
+ this.timeouts = new Set();
99
+
100
+ this.init();
101
+ }
102
+
103
+ init() {
104
+ // Set up metadata handling immediately (independent of transcript display)
105
+ this.setupMetadataHandlingOnLoad();
106
+
107
+ // Listen for time updates to highlight active transcript entry
108
+ this.player.on('timeupdate', this.handlers.timeupdate);
109
+
110
+ // Listen for audio description changes to reload transcript
111
+ this.player.on('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
112
+ this.player.on('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
113
+
114
+ // Reposition transcript when entering/exiting fullscreen
115
+ this.player.on('fullscreenchange', () => {
116
+ if (this.isVisible) {
117
+ // Re-setup drag/drop when entering/exiting fullscreen on mobile devices
118
+ // This enables drag/resize when entering fullscreen on mobile
119
+ const isMobile = window.innerWidth < 768;
120
+ if (isMobile) {
121
+ this.setupDragAndDrop();
122
+ }
123
+
124
+ // Only auto-position if user hasn't manually positioned it
125
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
126
+ // Add a small delay to ensure DOM has updated after fullscreen transition
127
+ this.setManagedTimeout(() => this.positionTranscript(), 100);
128
+ }
129
+ }
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Toggle transcript window visibility
135
+ */
136
+ toggleTranscript() {
137
+ if (this.isVisible) {
138
+ this.hideTranscript();
139
+ } else {
140
+ this.showTranscript();
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Show transcript window
146
+ */
147
+ showTranscript() {
148
+ if (this.transcriptWindow) {
149
+ this.transcriptWindow.style.display = 'flex';
150
+ this.isVisible = true;
151
+
152
+ if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
153
+ this.player.controlBar.updateTranscriptButton();
154
+ }
155
+
156
+ // Focus the settings button for keyboard accessibility
157
+ focusElement(this.settingsButton, { delay: 150 });
158
+ return;
159
+ }
160
+
161
+ // Create transcript window
162
+ this.createTranscriptWindow();
163
+ this.loadTranscriptData();
164
+
165
+ // Show the window
166
+ if (this.transcriptWindow) {
167
+ this.transcriptWindow.style.display = 'flex';
168
+
169
+ // Only auto-position if user hasn't manually positioned it
170
+ // This prevents overwriting saved positions from localStorage
171
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
172
+ this.setManagedTimeout(() => this.positionTranscript(), 0);
173
+ }
174
+
175
+ // Focus the settings button for keyboard accessibility
176
+ focusElement(this.settingsButton, { delay: 150 });
177
+ }
178
+ this.isVisible = true;
179
+ }
180
+
181
+ /**
182
+ * Hide transcript window
183
+ */
184
+ hideTranscript({ focusButton = false } = {}) {
185
+ if (this.transcriptWindow) {
186
+ this.transcriptWindow.style.display = 'none';
187
+ this.isVisible = false;
188
+ }
189
+ if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
190
+ this.draggableResizable.disablePointerResizeMode();
191
+ this.updateResizeOptionState();
192
+ }
193
+ this.hideResizeModeIndicator();
194
+ this.announceLive('');
195
+
196
+ // Update transcript button state in control bar
197
+ if (this.player.controlBar && typeof this.player.controlBar.updateTranscriptButton === 'function') {
198
+ this.player.controlBar.updateTranscriptButton();
199
+ }
200
+
201
+ if (focusButton) {
202
+ const transcriptButton = this.player.controlBar?.controls?.transcript;
203
+ if (transcriptButton && typeof transcriptButton.focus === 'function') {
204
+ transcriptButton.focus({ preventScroll: true });
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Create the transcript window UI
211
+ */
212
+ createTranscriptWindow() {
213
+ this.transcriptWindow = DOMUtils.createElement('div', {
214
+ className: `${this.player.options.classPrefix}-transcript-window`,
215
+ attributes: {
216
+ 'role': 'dialog',
217
+ 'aria-label': 'Video Transcript',
218
+ 'tabindex': '-1'
219
+ }
220
+ });
221
+
222
+ // Header (draggable)
223
+ this.transcriptHeader = DOMUtils.createElement('div', {
224
+ className: `${this.player.options.classPrefix}-transcript-header`,
225
+ attributes: {
226
+ 'tabindex': '0'
227
+ }
228
+ });
229
+
230
+ // Header left side (settings button + title)
231
+ this.headerLeft = DOMUtils.createElement('div', {
232
+ className: `${this.player.options.classPrefix}-transcript-header-left`
233
+ });
234
+
235
+ // Settings button
236
+ const settingsAriaLabel = i18n.t('transcript.settingsMenu');
237
+ this.settingsButton = DOMUtils.createElement('button', {
238
+ className: `${this.player.options.classPrefix}-transcript-settings`,
239
+ attributes: {
240
+ 'type': 'button',
241
+ 'aria-label': settingsAriaLabel,
242
+ 'aria-expanded': 'false'
243
+ }
244
+ });
245
+ this.settingsButton.appendChild(createIconElement('settings'));
246
+ DOMUtils.attachTooltip(this.settingsButton, settingsAriaLabel, this.player.options.classPrefix);
247
+ this.handlers.settingsClick = (e) => {
248
+ e.preventDefault();
249
+ e.stopPropagation();
250
+ if (this.settingsMenuVisible) {
251
+ this.hideSettingsMenu();
252
+ } else {
253
+ this.showSettingsMenu();
254
+ }
255
+ };
256
+ this.settingsButton.addEventListener('click', this.handlers.settingsClick);
257
+
258
+ // Keyboard handler for settings button
259
+ this.handlers.settingsKeydown = (e) => {
260
+ // D key to toggle keyboard drag mode
261
+ if (e.key === 'd' || e.key === 'D') {
262
+ e.preventDefault();
263
+ e.stopPropagation();
264
+ this.toggleKeyboardDragMode();
265
+ }
266
+ // R key to toggle resize mode
267
+ else if (e.key === 'r' || e.key === 'R') {
268
+ e.preventDefault();
269
+ e.stopPropagation();
270
+ this.toggleResizeMode();
271
+ }
272
+ // Escape to close menu if open
273
+ else if (e.key === 'Escape' && this.settingsMenuVisible) {
274
+ e.preventDefault();
275
+ e.stopPropagation();
276
+ this.hideSettingsMenu();
277
+ }
278
+ };
279
+ this.settingsButton.addEventListener('keydown', this.handlers.settingsKeydown);
280
+
281
+ const title = DOMUtils.createElement('h3', {
282
+ textContent: `${i18n.t('transcript.title')}. ${i18n.t('transcript.dragResizePrompt')}`
283
+ });
284
+
285
+ // Autoscroll checkbox
286
+ const autoscrollId = `${this.player.options.classPrefix}-transcript-autoscroll-${Date.now()}`;
287
+
288
+ const autoscrollLabel = DOMUtils.createElement('label', {
289
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-label`,
290
+ attributes: {
291
+ 'for': autoscrollId
292
+ }
293
+ });
294
+
295
+ this.autoscrollCheckbox = DOMUtils.createElement('input', {
296
+ attributes: {
297
+ 'id': autoscrollId,
298
+ 'type': 'checkbox'
299
+ }
300
+ });
301
+ // Set checked property directly (boolean attribute, not "true" string)
302
+ if (this.autoscrollEnabled) {
303
+ this.autoscrollCheckbox.checked = true;
304
+ }
305
+
306
+ const autoscrollText = DOMUtils.createElement('span', {
307
+ textContent: i18n.t('transcript.autoscroll'),
308
+ className: `${this.player.options.classPrefix}-transcript-autoscroll-text`
309
+ });
310
+
311
+ autoscrollLabel.appendChild(this.autoscrollCheckbox);
312
+ autoscrollLabel.appendChild(autoscrollText);
313
+
314
+ // Handle autoscroll checkbox change
315
+ this.autoscrollCheckbox.addEventListener('change', (e) => {
316
+ this.autoscrollEnabled = e.target.checked;
317
+ this.saveAutoscrollPreference();
318
+ });
319
+
320
+ this.transcriptHeader.appendChild(title);
321
+ this.headerLeft.appendChild(this.settingsButton);
322
+ this.headerLeft.appendChild(autoscrollLabel);
323
+
324
+ // Language selector (will be populated after tracks are loaded)
325
+ const selectId = `${this.player.options.classPrefix}-transcript-language-select-${Date.now()}`;
326
+ const { label: languageLabel, select: languageSelector } = createLabeledSelect({
327
+ classPrefix: this.player.options.classPrefix,
328
+ labelClass: `${this.player.options.classPrefix}-transcript-language-label`,
329
+ selectClass: `${this.player.options.classPrefix}-transcript-language-select`,
330
+ labelText: 'settings.language',
331
+ selectId: selectId,
332
+ hidden: false // Don't hide individual elements, we'll hide the wrapper instead
333
+ });
334
+
335
+ this.languageLabel = languageLabel;
336
+ this.languageSelector = languageSelector;
337
+
338
+ // Wrap label and select in a container for vertical stacking
339
+ const languageSelectorWrapper = DOMUtils.createElement('div', {
340
+ className: `${this.player.options.classPrefix}-transcript-language-wrapper`,
341
+ attributes: {
342
+ 'style': 'display: none;' // Hidden until we detect multiple languages
343
+ }
344
+ });
345
+ languageSelectorWrapper.appendChild(this.languageLabel);
346
+ languageSelectorWrapper.appendChild(this.languageSelector);
347
+ this.languageSelectorWrapper = languageSelectorWrapper;
348
+
349
+ // Prevent drag when interacting with wrapper
350
+ preventDragOnElement(languageSelectorWrapper);
351
+
352
+ this.headerLeft.appendChild(languageSelectorWrapper);
353
+
354
+ const closeAriaLabel = i18n.t('transcript.close');
355
+ const closeButton = DOMUtils.createElement('button', {
356
+ className: `${this.player.options.classPrefix}-transcript-close`,
357
+ attributes: {
358
+ 'type': 'button',
359
+ 'aria-label': closeAriaLabel
360
+ }
361
+ });
362
+ closeButton.appendChild(createIconElement('close'));
363
+ DOMUtils.attachTooltip(closeButton, closeAriaLabel, this.player.options.classPrefix);
364
+ closeButton.addEventListener('click', () => this.hideTranscript({ focusButton: true }));
365
+
366
+ this.transcriptHeader.appendChild(this.headerLeft);
367
+ this.transcriptHeader.appendChild(closeButton);
368
+
369
+ // Content container
370
+ this.transcriptContent = DOMUtils.createElement('div', {
371
+ className: `${this.player.options.classPrefix}-transcript-content`
372
+ });
373
+
374
+ this.transcriptWindow.appendChild(this.transcriptHeader);
375
+ this.transcriptWindow.appendChild(this.transcriptContent);
376
+
377
+ this.createResizeHandles();
378
+
379
+ // Live region for announcements (screen reader feedback)
380
+ this.liveRegion = DOMUtils.createElement('div', {
381
+ className: 'vidply-sr-only',
382
+ attributes: {
383
+ 'aria-live': 'polite',
384
+ 'aria-atomic': 'true'
385
+ }
386
+ });
387
+ this.transcriptWindow.appendChild(this.liveRegion);
388
+
389
+ // Append to player container
390
+ this.player.container.appendChild(this.transcriptWindow);
391
+
392
+ // Setup drag functionality FIRST (this will restore saved position if it exists)
393
+ this.setupDragAndDrop();
394
+
395
+ // Then position it next to the video wrapper ONLY if user hasn't manually positioned it
396
+ // This ensures we don't overwrite saved positions from localStorage
397
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
398
+ this.positionTranscript();
399
+ }
400
+
401
+ // Setup document click handler to close settings menu and style dialog
402
+ // DON'T add it yet - it will be added when the menu is first opened
403
+ this.handlers.documentClick = (e) => {
404
+ // Ignore if menu was just opened (prevents immediate closing)
405
+ if (this.settingsMenuJustOpened) {
406
+ return;
407
+ }
408
+
409
+ // Ignore if style dialog was just opened (prevents immediate closing)
410
+ if (this.styleDialogJustOpened) {
411
+ return;
412
+ }
413
+
414
+ // Ignore clicks on the settings button itself
415
+ if (this.settingsButton && this.settingsButton.contains(e.target)) {
416
+ return;
417
+ }
418
+
419
+ // Ignore clicks on the settings menu items
420
+ if (this.settingsMenu && this.settingsMenu.contains(e.target)) {
421
+ return;
422
+ }
423
+
424
+ // Close settings menu if clicking outside
425
+ if (this.settingsMenuVisible) {
426
+ this.hideSettingsMenu();
427
+ }
428
+
429
+ // Close style dialog if clicking outside (but not on settings button)
430
+ if (this.styleDialogVisible && this.styleDialog &&
431
+ !this.styleDialog.contains(e.target)) {
432
+ this.hideStyleDialog();
433
+ }
434
+ };
435
+ // Store flag to track if handler has been added
436
+ this.documentClickHandlerAdded = false;
437
+
438
+ // Re-position on window resize (debounced) - but only if not manually positioned
439
+ let resizeTimeout;
440
+ this.handlers.resize = () => {
441
+ if (resizeTimeout) {
442
+ this.clearManagedTimeout(resizeTimeout);
443
+ }
444
+ resizeTimeout = this.setManagedTimeout(() => {
445
+ // Only auto-position if user hasn't manually moved it
446
+ if (!this.draggableResizable || !this.draggableResizable.manuallyPositioned) {
447
+ this.positionTranscript();
448
+ }
449
+ }, 100);
450
+ };
451
+ window.addEventListener('resize', this.handlers.resize);
452
+ }
453
+
454
+ createResizeHandles() {
455
+ if (!this.transcriptWindow) return;
456
+
457
+ const directions = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
458
+ this.transcriptResizeHandles = directions.map(direction => {
459
+ const handle = DOMUtils.createElement('div', {
460
+ className: `${this.player.options.classPrefix}-transcript-resize-handle ${this.player.options.classPrefix}-transcript-resize-${direction}`,
461
+ attributes: {
462
+ 'data-direction': direction,
463
+ 'data-vidply-managed-resize': 'true',
464
+ 'aria-hidden': 'true'
465
+ }
466
+ });
467
+
468
+ handle.style.display = 'none';
469
+ this.transcriptWindow.appendChild(handle);
470
+ return handle;
471
+ });
472
+ }
473
+
474
+ /**
475
+ * Position transcript window next to video
476
+ */
477
+ positionTranscript() {
478
+ if (!this.transcriptWindow || !this.player.videoWrapper || !this.isVisible) return;
479
+
480
+ // Don't auto-position if user has manually positioned it
481
+ if (this.draggableResizable && this.draggableResizable.manuallyPositioned) {
482
+ return;
483
+ }
484
+
485
+ const isMobile = window.innerWidth < 768;
486
+ const videoRect = this.player.videoWrapper.getBoundingClientRect();
487
+
488
+ // Check if player is in fullscreen mode
489
+ const isFullscreen = this.player.state.fullscreen;
490
+
491
+ if (isMobile && !isFullscreen) {
492
+ // Mobile: Position underneath the video and controls as part of the layout
493
+ this.transcriptWindow.style.position = 'relative';
494
+ this.transcriptWindow.style.left = '0';
495
+ this.transcriptWindow.style.right = '0';
496
+ this.transcriptWindow.style.bottom = 'auto';
497
+ this.transcriptWindow.style.top = 'auto';
498
+ this.transcriptWindow.style.width = '100%';
499
+ this.transcriptWindow.style.maxWidth = '100%';
500
+ this.transcriptWindow.style.maxHeight = '400px';
501
+ this.transcriptWindow.style.height = 'auto';
502
+ this.transcriptWindow.style.borderRadius = '0';
503
+ this.transcriptWindow.style.transform = 'none';
504
+ this.transcriptWindow.style.border = 'none';
505
+ this.transcriptWindow.style.borderTop = '1px solid var(--vidply-border-light)';
506
+ // Remove any empty border properties that might have been set
507
+ this.transcriptWindow.style.removeProperty('border-right');
508
+ this.transcriptWindow.style.removeProperty('border-bottom');
509
+ this.transcriptWindow.style.removeProperty('border-left');
510
+ // Remove border-image properties that can cause parse errors
511
+ this.transcriptWindow.style.removeProperty('border-image');
512
+ this.transcriptWindow.style.removeProperty('border-image-source');
513
+ this.transcriptWindow.style.removeProperty('border-image-slice');
514
+ this.transcriptWindow.style.removeProperty('border-image-width');
515
+ this.transcriptWindow.style.removeProperty('border-image-outset');
516
+ this.transcriptWindow.style.removeProperty('border-image-repeat');
517
+ this.transcriptWindow.style.boxShadow = 'none';
518
+ // Disable dragging on mobile
519
+ if (this.transcriptHeader) {
520
+ this.transcriptHeader.style.cursor = 'default';
521
+ }
522
+
523
+ // Ensure transcript is at the container level for proper stacking
524
+ if (this.transcriptWindow.parentNode !== this.player.container) {
525
+ this.player.container.appendChild(this.transcriptWindow);
526
+ }
527
+ } else if (isFullscreen) {
528
+ // In fullscreen: position in bottom right corner inside the video
529
+ this.transcriptWindow.style.position = 'fixed';
530
+ this.transcriptWindow.style.left = 'auto';
531
+ this.transcriptWindow.style.right = '20px';
532
+ this.transcriptWindow.style.bottom = '80px'; // Above controls
533
+ this.transcriptWindow.style.top = 'auto';
534
+ this.transcriptWindow.style.maxHeight = 'calc(100vh - 180px)'; // Leave space for controls
535
+ this.transcriptWindow.style.height = 'auto';
536
+ const fullscreenMinWidth = 260;
537
+ const fullscreenAvailable = Math.max(fullscreenMinWidth, window.innerWidth - 40);
538
+ const fullscreenDesired = parseFloat(this.transcriptWindow.style.width) || 400;
539
+ const fullscreenWidth = Math.max(fullscreenMinWidth, Math.min(fullscreenDesired, fullscreenAvailable));
540
+ this.transcriptWindow.style.width = `${fullscreenWidth}px`;
541
+ this.transcriptWindow.style.maxWidth = 'none';
542
+ this.transcriptWindow.style.borderRadius = '8px';
543
+ this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
544
+ // Remove borderTop and any other individual border properties to avoid empty values
545
+ this.transcriptWindow.style.removeProperty('border-top');
546
+ this.transcriptWindow.style.removeProperty('border-right');
547
+ this.transcriptWindow.style.removeProperty('border-bottom');
548
+ this.transcriptWindow.style.removeProperty('border-left');
549
+ // Remove border-image properties that can cause parse errors
550
+ this.transcriptWindow.style.removeProperty('border-image');
551
+ this.transcriptWindow.style.removeProperty('border-image-source');
552
+ this.transcriptWindow.style.removeProperty('border-image-slice');
553
+ this.transcriptWindow.style.removeProperty('border-image-width');
554
+ this.transcriptWindow.style.removeProperty('border-image-outset');
555
+ this.transcriptWindow.style.removeProperty('border-image-repeat');
556
+ // Enable dragging in fullscreen (including touch devices)
557
+ if (this.transcriptHeader) {
558
+ this.transcriptHeader.style.cursor = 'move';
559
+ }
560
+
561
+ // Move back to container for fullscreen
562
+ if (this.transcriptWindow.parentNode !== this.player.container) {
563
+ this.player.container.appendChild(this.transcriptWindow);
564
+ }
565
+ } else {
566
+ // Desktop mode: position in right side of viewport
567
+ const transcriptWidth = parseFloat(this.transcriptWindow.style.width) || 400;
568
+ const padding = 20;
569
+ const minWidth = 260;
570
+ const containerRect = this.player.container.getBoundingClientRect();
571
+
572
+ const ensureContainerPositioned = () => {
573
+ const computed = window.getComputedStyle(this.player.container);
574
+ if (computed.position === 'static') {
575
+ this.player.container.style.position = 'relative';
576
+ }
577
+ };
578
+
579
+ ensureContainerPositioned();
580
+
581
+ const left = (videoRect.right - containerRect.left) + padding;
582
+ const availableWidth = window.innerWidth - videoRect.right - padding;
583
+ const appliedWidth = Math.max(minWidth, Math.min(transcriptWidth, availableWidth));
584
+ const appliedHeight = videoRect.height;
585
+
586
+ this.transcriptWindow.style.position = 'absolute';
587
+ this.transcriptWindow.style.left = `${left}px`;
588
+ this.transcriptWindow.style.right = 'auto';
589
+ this.transcriptWindow.style.bottom = 'auto';
590
+ this.transcriptWindow.style.top = '0';
591
+ this.transcriptWindow.style.height = `${appliedHeight}px`;
592
+ this.transcriptWindow.style.maxHeight = 'none';
593
+ this.transcriptWindow.style.width = `${appliedWidth}px`;
594
+ this.transcriptWindow.style.maxWidth = 'none';
595
+ this.transcriptWindow.style.borderRadius = '8px';
596
+ this.transcriptWindow.style.border = '1px solid var(--vidply-border)';
597
+ // Remove borderTop and any other individual border properties to avoid empty values
598
+ this.transcriptWindow.style.removeProperty('border-top');
599
+ this.transcriptWindow.style.removeProperty('border-right');
600
+ this.transcriptWindow.style.removeProperty('border-bottom');
601
+ this.transcriptWindow.style.removeProperty('border-left');
602
+ // Remove border-image properties that can cause parse errors
603
+ this.transcriptWindow.style.removeProperty('border-image');
604
+ this.transcriptWindow.style.removeProperty('border-image-source');
605
+ this.transcriptWindow.style.removeProperty('border-image-slice');
606
+ this.transcriptWindow.style.removeProperty('border-image-width');
607
+ this.transcriptWindow.style.removeProperty('border-image-outset');
608
+ this.transcriptWindow.style.removeProperty('border-image-repeat');
609
+ // Enable dragging on desktop
610
+ if (this.transcriptHeader) {
611
+ this.transcriptHeader.style.cursor = 'move';
612
+ }
613
+
614
+ // Move back to container for desktop
615
+ if (this.transcriptWindow.parentNode !== this.player.container) {
616
+ this.player.container.appendChild(this.transcriptWindow);
617
+ }
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Get available transcript languages from tracks
623
+ */
624
+ getAvailableTranscriptLanguages() {
625
+ const textTracks = this.player.textTracks;
626
+ const languages = new Map();
627
+
628
+ // Collect all caption/subtitle tracks with their languages
629
+ textTracks.forEach(track => {
630
+ if ((track.kind === 'captions' || track.kind === 'subtitles') && track.language) {
631
+ if (!languages.has(track.language)) {
632
+ languages.set(track.language, {
633
+ language: track.language,
634
+ label: track.label || track.language,
635
+ track: track
636
+ });
637
+ }
638
+ }
639
+ });
640
+
641
+ return Array.from(languages.values());
642
+ }
643
+
644
+ /**
645
+ * Update language selector dropdown
646
+ */
647
+ updateLanguageSelector() {
648
+ if (!this.languageSelector) return;
649
+
650
+ this.availableTranscriptLanguages = this.getAvailableTranscriptLanguages();
651
+
652
+ // Clear existing options
653
+ this.languageSelector.innerHTML = '';
654
+
655
+ // Only show selector if there are 2+ languages
656
+ if (this.availableTranscriptLanguages.length < 2) {
657
+ if (this.languageSelectorWrapper) {
658
+ this.languageSelectorWrapper.style.display = 'none';
659
+ }
660
+ return;
661
+ }
662
+
663
+ // Show selector wrapper
664
+ if (this.languageSelectorWrapper) {
665
+ this.languageSelectorWrapper.style.display = 'flex';
666
+ }
667
+
668
+ this.availableTranscriptLanguages.forEach((langInfo, index) => {
669
+ const option = DOMUtils.createElement('option', {
670
+ textContent: langInfo.label,
671
+ attributes: {
672
+ 'value': langInfo.language,
673
+ 'lang': langInfo.language
674
+ }
675
+ });
676
+ this.languageSelector.appendChild(option);
677
+ });
678
+
679
+ // Set current selection
680
+ if (this.currentTranscriptLanguage) {
681
+ this.languageSelector.value = this.currentTranscriptLanguage;
682
+ } else if (this.availableTranscriptLanguages.length > 0) {
683
+ // Default to first language or active track
684
+ const activeTrack = this.player.textTracks.find(
685
+ track => (track.kind === 'captions' || track.kind === 'subtitles') && track.mode === 'showing'
686
+ );
687
+ this.currentTranscriptLanguage = activeTrack ? activeTrack.language : this.availableTranscriptLanguages[0].language;
688
+ this.languageSelector.value = this.currentTranscriptLanguage;
689
+ }
690
+
691
+ // Remove existing change listener if any
692
+ if (this.languageSelectorHandler) {
693
+ this.languageSelector.removeEventListener('change', this.languageSelectorHandler);
694
+ }
695
+
696
+ // Handle language change
697
+ this.languageSelectorHandler = (e) => {
698
+ this.currentTranscriptLanguage = e.target.value;
699
+ this.loadTranscriptData();
700
+
701
+ // Set lang attribute for screen readers to pronounce text correctly
702
+ if (this.transcriptContent && this.currentTranscriptLanguage) {
703
+ this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
704
+ }
705
+ };
706
+ this.languageSelector.addEventListener('change', this.languageSelectorHandler);
707
+ }
708
+
709
+ /**
710
+ * Load transcript data from caption/subtitle tracks
711
+ */
712
+ loadTranscriptData() {
713
+ this.transcriptEntries = [];
714
+ this.transcriptContent.innerHTML = '';
715
+
716
+ // Get all text tracks
717
+ const textTracks = this.player.textTracks;
718
+
719
+ // Find track for selected language, or default to first available
720
+ let captionTrack = null;
721
+ if (this.currentTranscriptLanguage) {
722
+ captionTrack = textTracks.find(
723
+ track => (track.kind === 'captions' || track.kind === 'subtitles') &&
724
+ track.language === this.currentTranscriptLanguage
725
+ );
726
+ }
727
+
728
+ // Fallback to first available caption/subtitle track
729
+ if (!captionTrack) {
730
+ captionTrack = textTracks.find(
731
+ track => track.kind === 'captions' || track.kind === 'subtitles'
732
+ );
733
+ if (captionTrack) {
734
+ this.currentTranscriptLanguage = captionTrack.language;
735
+ }
736
+ }
737
+
738
+ // Find description track matching the selected language
739
+ let descriptionTrack = null;
740
+ if (this.currentTranscriptLanguage) {
741
+ descriptionTrack = textTracks.find(
742
+ track => track.kind === 'descriptions' && track.language === this.currentTranscriptLanguage
743
+ );
744
+ }
745
+ // Fallback to first available description track if no match found
746
+ if (!descriptionTrack) {
747
+ descriptionTrack = textTracks.find(track => track.kind === 'descriptions');
748
+ }
749
+
750
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
751
+
752
+ // We need at least one track type available for display
753
+ // Description tracks are only included if audio description is enabled
754
+ const hasDescriptionTrack = descriptionTrack && this.player.state.audioDescriptionEnabled;
755
+ if (!captionTrack && !hasDescriptionTrack && !metadataTrack) {
756
+ this.showNoTranscriptMessage();
757
+ return;
758
+ }
759
+
760
+ // Enable all tracks to load cues (even if we won't display descriptions)
761
+ // This ensures descriptions are ready when audio description is enabled
762
+ const tracksToLoad = [captionTrack, descriptionTrack, metadataTrack].filter(Boolean);
763
+ tracksToLoad.forEach(track => {
764
+ if (track.mode === 'disabled') {
765
+ track.mode = 'hidden';
766
+ }
767
+ });
768
+
769
+ // Check if any tracks are still loading
770
+ const needsLoading = tracksToLoad.some(track => !track.cues || track.cues.length === 0);
771
+
772
+ if (needsLoading) {
773
+ // Wait for cues to load
774
+ const loadingMessage = DOMUtils.createElement('div', {
775
+ className: `${this.player.options.classPrefix}-transcript-loading`,
776
+ textContent: i18n.t('transcript.loading')
777
+ });
778
+ this.transcriptContent.appendChild(loadingMessage);
779
+
780
+ let loaded = 0;
781
+ const onLoad = () => {
782
+ loaded++;
783
+ if (loaded >= tracksToLoad.length) {
784
+ this.loadTranscriptData();
785
+ }
786
+ };
787
+
788
+ tracksToLoad.forEach(track => {
789
+ track.addEventListener('load', onLoad, { once: true });
790
+ });
791
+
792
+ // Fallback timeout
793
+ this.setManagedTimeout(() => {
794
+ this.loadTranscriptData();
795
+ }, 500);
796
+
797
+ return;
798
+ }
799
+
800
+ // Collect all cues from all tracks with their type
801
+ const allCues = [];
802
+
803
+ if (captionTrack && captionTrack.cues) {
804
+ Array.from(captionTrack.cues).forEach(cue => {
805
+ allCues.push({ cue, type: 'caption' });
806
+ });
807
+ }
808
+
809
+ // Only include description cues if audio description is enabled
810
+ if (descriptionTrack && descriptionTrack.cues && this.player.state.audioDescriptionEnabled) {
811
+ Array.from(descriptionTrack.cues).forEach(cue => {
812
+ allCues.push({ cue, type: 'description' });
813
+ });
814
+ }
815
+
816
+ // Store metadata separately for programmatic use (don't display in transcript)
817
+ if (metadataTrack && metadataTrack.cues) {
818
+ this.metadataCues = Array.from(metadataTrack.cues);
819
+ this.setupMetadataHandling();
820
+ }
821
+
822
+ // Sort all cues by start time
823
+ allCues.sort((a, b) => a.cue.startTime - b.cue.startTime);
824
+
825
+ // Build transcript from captions and descriptions only
826
+ allCues.forEach((item, index) => {
827
+ const entry = this.createTranscriptEntry(item.cue, index, item.type);
828
+ this.transcriptEntries.push({
829
+ element: entry,
830
+ cue: item.cue,
831
+ type: item.type,
832
+ startTime: item.cue.startTime,
833
+ endTime: item.cue.endTime
834
+ });
835
+ this.transcriptContent.appendChild(entry);
836
+ });
837
+
838
+ // Apply current styles to newly loaded entries
839
+ this.applyTranscriptStyles();
840
+
841
+ // Apply timestamp visibility preference
842
+ this.updateTimestampVisibility();
843
+
844
+ // Set lang attribute for screen readers to pronounce text correctly
845
+ if (this.transcriptContent && this.currentTranscriptLanguage) {
846
+ this.transcriptContent.setAttribute('lang', this.currentTranscriptLanguage);
847
+ }
848
+
849
+ // Update language selector after loading
850
+ this.updateLanguageSelector();
851
+ }
852
+
853
+ /**
854
+ * Setup metadata handling on player load
855
+ * This runs independently of transcript loading
856
+ */
857
+ setupMetadataHandlingOnLoad() {
858
+ // Wait for metadata to be loaded
859
+ const setupMetadata = () => {
860
+ const textTracks = this.player.textTracks;
861
+ const metadataTrack = textTracks.find(track => track.kind === 'metadata');
862
+
863
+ if (metadataTrack) {
864
+ // Enable the metadata track so cuechange events fire
865
+ // Use 'hidden' mode so it doesn't display anything, but events still work
866
+ if (metadataTrack.mode === 'disabled') {
867
+ metadataTrack.mode = 'hidden';
868
+ }
869
+
870
+ // Check if we already added the listener
871
+ if (this.metadataCueChangeHandler) {
872
+ metadataTrack.removeEventListener('cuechange', this.metadataCueChangeHandler);
873
+ }
874
+
875
+ // Add event listener for cue changes
876
+ this.metadataCueChangeHandler = () => {
877
+ const activeCues = Array.from(metadataTrack.activeCues || []);
878
+ if (activeCues.length > 0) {
879
+ // Debug logging (can be removed in production)
880
+ if (this.player.options.debug) {
881
+ console.log('[VidPly Metadata] Active cues:', activeCues.map(c => ({
882
+ start: c.startTime,
883
+ end: c.endTime,
884
+ text: c.text
885
+ })));
886
+ }
887
+ }
888
+ activeCues.forEach(cue => {
889
+ this.handleMetadataCue(cue);
890
+ });
891
+ };
892
+
893
+ metadataTrack.addEventListener('cuechange', this.metadataCueChangeHandler);
894
+
895
+ // Debug: Log metadata track setup
896
+ if (this.player.options.debug) {
897
+ const cueCount = metadataTrack.cues ? metadataTrack.cues.length : 0;
898
+ console.log('[VidPly Metadata] Track enabled,', cueCount, 'cues available');
899
+ }
900
+ } else if (this.player.options.debug) {
901
+ console.warn('[VidPly Metadata] No metadata track found');
902
+ }
903
+ };
904
+
905
+ // Try immediately
906
+ setupMetadata();
907
+
908
+ // Also try after loadedmetadata event
909
+ this.player.on('loadedmetadata', setupMetadata);
910
+ }
911
+
912
+ /**
913
+ * Setup metadata handling
914
+ * Metadata cues are not displayed but can be used programmatically
915
+ * This is called when transcript data is loaded (for storing cues)
916
+ */
917
+ setupMetadataHandling() {
918
+ if (!this.metadataCues || this.metadataCues.length === 0) {
919
+ return;
920
+ }
921
+
922
+ // The actual event handling is set up in setupMetadataHandlingOnLoad()
923
+ // This method just stores the cues for reference
924
+ if (this.player.options.debug) {
925
+ console.log('[VidPly Metadata]', this.metadataCues.length, 'cues stored from transcript load');
926
+ }
927
+ }
928
+
929
+ /**
930
+ * Handle individual metadata cues
931
+ * Parses metadata text and emits events or triggers actions
932
+ */
933
+ handleMetadataCue(cue) {
934
+ const text = cue.text.trim();
935
+
936
+ // Debug logging
937
+ if (this.player.options.debug) {
938
+ console.log('[VidPly Metadata] Processing cue:', {
939
+ time: cue.startTime,
940
+ text: text
941
+ });
942
+ }
943
+
944
+ // Emit a generic metadata event that developers can listen to
945
+ this.player.emit('metadata', {
946
+ time: cue.startTime,
947
+ endTime: cue.endTime,
948
+ text: text,
949
+ cue: cue
950
+ });
951
+
952
+ // Parse for specific commands (examples based on wwa_meta.vtt format)
953
+ if (text.includes('PAUSE')) {
954
+ // Automatically pause the video
955
+ if (!this.player.state.paused) {
956
+ if (this.player.options.debug) {
957
+ console.log('[VidPly Metadata] Pausing video at', cue.startTime);
958
+ }
959
+ this.player.pause();
960
+ }
961
+ // Also emit event for developers who want to listen
962
+ this.player.emit('metadata:pause', { time: cue.startTime, text: text });
963
+ }
964
+
965
+ // Parse for focus directives
966
+ const focusMatch = text.match(/FOCUS:([\w#-]+)/);
967
+ if (focusMatch) {
968
+ const targetSelector = focusMatch[1];
969
+ // Automatically focus the target element
970
+ const targetElement = document.querySelector(targetSelector);
971
+ if (targetElement) {
972
+ if (this.player.options.debug) {
973
+ console.log('[VidPly Metadata] Focusing element:', targetSelector);
974
+ }
975
+ // Use setTimeout to ensure DOM is ready
976
+ this.setManagedTimeout(() => {
977
+ targetElement.focus({ preventScroll: true });
978
+ }, 10);
979
+ } else if (this.player.options.debug) {
980
+ console.warn('[VidPly Metadata] Element not found:', targetSelector);
981
+ }
982
+ // Also emit event for developers who want to listen
983
+ this.player.emit('metadata:focus', {
984
+ time: cue.startTime,
985
+ target: targetSelector,
986
+ element: targetElement,
987
+ text: text
988
+ });
989
+ }
990
+
991
+ // Parse for hashtag references
992
+ const hashtags = text.match(/#[\w-]+/g);
993
+ if (hashtags) {
994
+ if (this.player.options.debug) {
995
+ console.log('[VidPly Metadata] Hashtags found:', hashtags);
996
+ }
997
+ this.player.emit('metadata:hashtags', {
998
+ time: cue.startTime,
999
+ hashtags: hashtags,
1000
+ text: text
1001
+ });
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Create a single transcript entry element
1007
+ */
1008
+ createTranscriptEntry(cue, index, type = 'caption') {
1009
+ const entryText = this.stripVTTFormatting(cue.text);
1010
+
1011
+ const entry = DOMUtils.createElement('div', {
1012
+ className: `${this.player.options.classPrefix}-transcript-entry ${this.player.options.classPrefix}-transcript-${type}`,
1013
+ attributes: {
1014
+ 'tabindex': '0',
1015
+ 'data-start': String(cue.startTime),
1016
+ 'data-end': String(cue.endTime),
1017
+ 'data-type': type
1018
+ }
1019
+ });
1020
+
1021
+ const timestamp = DOMUtils.createElement('span', {
1022
+ className: `${this.player.options.classPrefix}-transcript-time`,
1023
+ textContent: TimeUtils.formatTime(cue.startTime),
1024
+ attributes: {
1025
+ 'aria-hidden': 'true' // Hide from screen readers - decorative timestamp
1026
+ }
1027
+ });
1028
+
1029
+ const text = DOMUtils.createElement('span', {
1030
+ className: `${this.player.options.classPrefix}-transcript-text`,
1031
+ textContent: entryText
1032
+ });
1033
+
1034
+ entry.appendChild(timestamp);
1035
+ entry.appendChild(text);
1036
+
1037
+ // Click to seek
1038
+ const seekToTime = () => {
1039
+ this.player.seek(cue.startTime);
1040
+ if (this.player.state.paused) {
1041
+ this.player.play();
1042
+ }
1043
+ };
1044
+
1045
+ entry.addEventListener('click', seekToTime);
1046
+ entry.addEventListener('keydown', (e) => {
1047
+ if (e.key === 'Enter' || e.key === ' ') {
1048
+ e.preventDefault();
1049
+ seekToTime();
1050
+ }
1051
+ });
1052
+
1053
+ return entry;
1054
+ }
1055
+
1056
+ /**
1057
+ * Strip VTT formatting tags from text
1058
+ */
1059
+ stripVTTFormatting(text) {
1060
+ // Remove VTT tags like <v Speaker>, <c>, etc.
1061
+ return text
1062
+ .replace(/<[^>]+>/g, '')
1063
+ .replace(/\n/g, ' ')
1064
+ .trim();
1065
+ }
1066
+
1067
+ /**
1068
+ * Show message when no transcript is available
1069
+ */
1070
+ showNoTranscriptMessage() {
1071
+ const message = DOMUtils.createElement('div', {
1072
+ className: `${this.player.options.classPrefix}-transcript-empty`,
1073
+ textContent: i18n.t('transcript.noTranscript')
1074
+ });
1075
+ this.transcriptContent.appendChild(message);
1076
+ }
1077
+
1078
+ /**
1079
+ * Update active transcript entry based on current time
1080
+ */
1081
+ updateActiveEntry() {
1082
+ if (!this.isVisible || this.transcriptEntries.length === 0) return;
1083
+
1084
+ const currentTime = this.player.state.currentTime;
1085
+
1086
+ // Find the entry that matches current time
1087
+ const activeEntry = this.transcriptEntries.find(
1088
+ entry => currentTime >= entry.startTime && currentTime < entry.endTime
1089
+ );
1090
+
1091
+ if (activeEntry && activeEntry !== this.currentActiveEntry) {
1092
+ // Remove previous active class
1093
+ if (this.currentActiveEntry) {
1094
+ this.currentActiveEntry.element.classList.remove(
1095
+ `${this.player.options.classPrefix}-transcript-entry-active`
1096
+ );
1097
+ }
1098
+
1099
+ // Add active class to current entry
1100
+ activeEntry.element.classList.add(
1101
+ `${this.player.options.classPrefix}-transcript-entry-active`
1102
+ );
1103
+
1104
+ // Scroll to active entry
1105
+ this.scrollToEntry(activeEntry.element);
1106
+
1107
+ this.currentActiveEntry = activeEntry;
1108
+ } else if (!activeEntry && this.currentActiveEntry) {
1109
+ // No active entry, remove active class
1110
+ this.currentActiveEntry.element.classList.remove(
1111
+ `${this.player.options.classPrefix}-transcript-entry-active`
1112
+ );
1113
+ this.currentActiveEntry = null;
1114
+ }
1115
+ }
1116
+
1117
+ /**
1118
+ * Scroll transcript window to show active entry
1119
+ */
1120
+ scrollToEntry(entryElement) {
1121
+ if (!this.transcriptContent || !this.autoscrollEnabled) return;
1122
+
1123
+ const contentRect = this.transcriptContent.getBoundingClientRect();
1124
+ const entryRect = entryElement.getBoundingClientRect();
1125
+
1126
+ // Check if entry is out of view
1127
+ if (entryRect.top < contentRect.top || entryRect.bottom > contentRect.bottom) {
1128
+ // Scroll to center the entry
1129
+ const scrollTop = entryElement.offsetTop - (this.transcriptContent.clientHeight / 2) + (entryElement.clientHeight / 2);
1130
+ this.transcriptContent.scrollTo({
1131
+ top: scrollTop,
1132
+ behavior: 'smooth'
1133
+ });
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * Save autoscroll preference to localStorage
1139
+ */
1140
+ saveAutoscrollPreference() {
1141
+ const savedPreferences = this.storage.getTranscriptPreferences() || {};
1142
+ savedPreferences.autoscroll = this.autoscrollEnabled;
1143
+ this.storage.saveTranscriptPreferences(savedPreferences);
1144
+ }
1145
+
1146
+ /**
1147
+ * Setup drag and drop functionality
1148
+ */
1149
+ setupDragAndDrop() {
1150
+ if (!this.transcriptHeader || !this.transcriptWindow) return;
1151
+
1152
+ // Check if we're on mobile and not in fullscreen
1153
+ const isMobile = window.innerWidth < 768;
1154
+ const isFullscreen = this.player.state.fullscreen;
1155
+
1156
+ // On mobile devices (< 768px), only enable drag/resize in fullscreen
1157
+ // On desktop/tablets (>= 768px), always enable drag/resize
1158
+ if (isMobile && !isFullscreen) {
1159
+ // Destroy existing instance if exiting fullscreen on mobile
1160
+ if (this.draggableResizable) {
1161
+ this.draggableResizable.destroy();
1162
+ this.draggableResizable = null;
1163
+ }
1164
+ return; // No drag/resize on mobile when not in fullscreen
1165
+ }
1166
+
1167
+ // If already initialized, don't re-initialize
1168
+ if (this.draggableResizable) {
1169
+ return;
1170
+ }
1171
+
1172
+ // Create DraggableResizable utility with touch support
1173
+ this.draggableResizable = new DraggableResizable(this.transcriptWindow, {
1174
+ dragHandle: this.transcriptHeader,
1175
+ resizeHandles: this.transcriptResizeHandles,
1176
+ constrainToViewport: true,
1177
+ classPrefix: `${this.player.options.classPrefix}-transcript`,
1178
+ keyboardDragKey: 'd',
1179
+ keyboardResizeKey: 'r',
1180
+ keyboardStep: 10,
1181
+ keyboardStepLarge: 50,
1182
+ minWidth: 300,
1183
+ minHeight: 200,
1184
+ maxWidth: () => Math.max(320, window.innerWidth - 40),
1185
+ maxHeight: () => Math.max(200, window.innerHeight - 120),
1186
+ pointerResizeIndicatorText: i18n.t('transcript.resizeModeHint'),
1187
+ onPointerResizeToggle: (enabled) => {
1188
+ // Update resize handles visibility
1189
+ this.transcriptResizeHandles.forEach(handle => {
1190
+ handle.style.display = enabled ? 'block' : 'none';
1191
+ });
1192
+ // Call the state change handler
1193
+ this.onPointerResizeModeChange(enabled);
1194
+ },
1195
+ onDragStart: (e) => {
1196
+ // Don't drag if clicking on certain elements
1197
+ const ignoreSelectors = [
1198
+ `.${this.player.options.classPrefix}-transcript-close`,
1199
+ `.${this.player.options.classPrefix}-transcript-settings`,
1200
+ `.${this.player.options.classPrefix}-transcript-language-select`,
1201
+ `.${this.player.options.classPrefix}-transcript-language-label`,
1202
+ `.${this.player.options.classPrefix}-transcript-settings-menu`,
1203
+ `.${this.player.options.classPrefix}-transcript-style-dialog`
1204
+ ];
1205
+
1206
+ for (const selector of ignoreSelectors) {
1207
+ if (e.target.closest(selector)) {
1208
+ return false; // Prevent drag
1209
+ }
1210
+ }
1211
+
1212
+ return true; // Allow drag
1213
+ }
1214
+ });
1215
+
1216
+ // Add custom keyboard handler for special keys (Escape, Home)
1217
+ this.customKeyHandler = (e) => {
1218
+ const key = e.key.toLowerCase();
1219
+ const alreadyPrevented = e.defaultPrevented;
1220
+
1221
+ // Don't handle keys if settings menu or style dialog is open (let them handle keys)
1222
+ if (this.settingsMenuVisible || this.styleDialogVisible) {
1223
+ return;
1224
+ }
1225
+
1226
+ if (key === 'home') {
1227
+ e.preventDefault();
1228
+ e.stopPropagation();
1229
+ if (this.draggableResizable) {
1230
+ if (this.draggableResizable.pointerResizeMode) {
1231
+ this.draggableResizable.disablePointerResizeMode();
1232
+ }
1233
+ this.draggableResizable.manuallyPositioned = false;
1234
+ this.positionTranscript();
1235
+ this.updateResizeOptionState();
1236
+ this.announceLive(i18n.t('transcript.positionReset'));
1237
+ }
1238
+ return;
1239
+ }
1240
+
1241
+ if (key === 'r') {
1242
+ if (alreadyPrevented) {
1243
+ return;
1244
+ }
1245
+ e.preventDefault();
1246
+ e.stopPropagation();
1247
+ const enabled = this.toggleResizeMode();
1248
+ if (enabled) {
1249
+ this.transcriptWindow.focus({ preventScroll: true });
1250
+ }
1251
+ return;
1252
+ }
1253
+
1254
+ if (key === 'escape') {
1255
+ // Check priority: resize mode > drag mode > close transcript
1256
+ // (settings menu and style dialog already handled by early return above)
1257
+ if (this.draggableResizable && this.draggableResizable.pointerResizeMode) {
1258
+ e.preventDefault();
1259
+ e.stopPropagation();
1260
+ this.draggableResizable.disablePointerResizeMode();
1261
+ return;
1262
+ }
1263
+ if (this.draggableResizable && this.draggableResizable.keyboardDragMode) {
1264
+ e.preventDefault();
1265
+ e.stopPropagation();
1266
+ this.draggableResizable.disableKeyboardDragMode();
1267
+ this.announceLive(i18n.t('transcript.dragModeDisabled'));
1268
+ return;
1269
+ }
1270
+ // Only close transcript if nothing else is open
1271
+ e.preventDefault();
1272
+ e.stopPropagation();
1273
+ this.hideTranscript({ focusButton: true });
1274
+ return;
1275
+ }
1276
+ };
1277
+
1278
+ this.transcriptWindow.addEventListener('keydown', this.customKeyHandler);
1279
+ }
1280
+
1281
+
1282
+ /**
1283
+ * Toggle keyboard drag mode
1284
+ */
1285
+ toggleKeyboardDragMode() {
1286
+ if (this.draggableResizable) {
1287
+ const wasEnabled = this.draggableResizable.keyboardDragMode;
1288
+ this.draggableResizable.toggleKeyboardDragMode();
1289
+ const isEnabled = this.draggableResizable.keyboardDragMode;
1290
+ if (!wasEnabled && isEnabled) {
1291
+ this.enableMoveMode();
1292
+ }
1293
+
1294
+ // Update drag option state
1295
+ this.updateDragOptionState();
1296
+
1297
+ // Hide settings menu if open
1298
+ if (this.settingsMenuVisible) {
1299
+ this.hideSettingsMenu();
1300
+ }
1301
+
1302
+ // Focus the window for keyboard navigation
1303
+ this.transcriptWindow.focus({ preventScroll: true });
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * Toggle settings menu visibility
1309
+ */
1310
+ toggleSettingsMenu() {
1311
+ if (this.settingsMenuVisible) {
1312
+ this.hideSettingsMenu();
1313
+ } else {
1314
+ this.showSettingsMenu();
1315
+ }
1316
+ }
1317
+
1318
+ /**
1319
+ * Show settings menu
1320
+ */
1321
+ showSettingsMenu() {
1322
+ // Set flag to prevent immediate closing
1323
+ this.settingsMenuJustOpened = true;
1324
+ setTimeout(() => {
1325
+ this.settingsMenuJustOpened = false;
1326
+ }, 350);
1327
+
1328
+ // Add document click handler on FIRST menu open (not at window creation)
1329
+ if (!this.documentClickHandlerAdded) {
1330
+ setTimeout(() => {
1331
+ document.addEventListener('click', this.handlers.documentClick);
1332
+ this.documentClickHandlerAdded = true;
1333
+ }, 300);
1334
+ }
1335
+
1336
+ if (this.settingsMenu) {
1337
+ this.settingsMenu.style.display = 'block';
1338
+ this.settingsMenuVisible = true;
1339
+ if (this.settingsButton) {
1340
+ this.settingsButton.setAttribute('aria-expanded', 'true');
1341
+ }
1342
+ // Re-attach keyboard navigation handler
1343
+ this.attachSettingsMenuKeyboardNavigation();
1344
+ // Position menu immediately
1345
+ this.positionSettingsMenuImmediate();
1346
+ this.updateResizeOptionState();
1347
+ // Focus first menu item after positioning
1348
+ setTimeout(() => {
1349
+ const menuItems = this.settingsMenu.querySelectorAll(`.${this.player.options.classPrefix}-transcript-settings-item`);
1350
+ if (menuItems.length > 0) {
1351
+ menuItems[0].setAttribute('tabindex', '0');
1352
+ for (let i = 1; i < menuItems.length; i++) {
1353
+ menuItems[i].setAttribute('tabindex', '-1');
1354
+ }
1355
+ menuItems[0].focus({ preventScroll: true });
1356
+ }
1357
+ }, 50);
1358
+ return;
1359
+ }
1360
+ // Create settings menu
1361
+ this.settingsMenu = DOMUtils.createElement('div', {
1362
+ className: `${this.player.options.classPrefix}-transcript-settings-menu`,
1363
+ attributes: {
1364
+ 'role': 'menu'
1365
+ }
1366
+ });
1367
+
1368
+ // Keyboard drag option
1369
+ const keyboardDragOption = createMenuItem({
1370
+ classPrefix: this.player.options.classPrefix,
1371
+ itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1372
+ icon: 'move',
1373
+ label: 'transcript.enableDragMode',
1374
+ hasTextClass: true,
1375
+ onClick: () => {
1376
+ this.toggleKeyboardDragMode();
1377
+ this.hideSettingsMenu();
1378
+ }
1379
+ });
1380
+ keyboardDragOption.setAttribute('role', 'switch');
1381
+ keyboardDragOption.setAttribute('aria-checked', 'false');
1382
+ // Remove any tooltips from menu items (they have visible text)
1383
+ const dragTooltip = keyboardDragOption.querySelector(`.${this.player.options.classPrefix}-tooltip`);
1384
+ if (dragTooltip) dragTooltip.remove();
1385
+ const dragButtonText = keyboardDragOption.querySelector(`.${this.player.options.classPrefix}-button-text`);
1386
+ if (dragButtonText) dragButtonText.remove();
1387
+ this.dragOptionButton = keyboardDragOption;
1388
+ this.dragOptionText = keyboardDragOption.querySelector(`.${this.player.options.classPrefix}-settings-text`);
1389
+ this.updateDragOptionState();
1390
+
1391
+ // Style option
1392
+ const styleOption = createMenuItem({
1393
+ classPrefix: this.player.options.classPrefix,
1394
+ itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1395
+ icon: 'settings',
1396
+ label: 'transcript.styleTranscript',
1397
+ onClick: (e) => {
1398
+ e.preventDefault();
1399
+ e.stopPropagation();
1400
+ this.hideSettingsMenu();
1401
+ // Delay to ensure menu is fully closed before opening dialog
1402
+ setTimeout(() => {
1403
+ this.showStyleDialog();
1404
+ }, 50);
1405
+ }
1406
+ });
1407
+ // Remove any tooltips from menu items (they have visible text)
1408
+ const styleTooltip = styleOption.querySelector(`.${this.player.options.classPrefix}-tooltip`);
1409
+ if (styleTooltip) styleTooltip.remove();
1410
+ const styleButtonText = styleOption.querySelector(`.${this.player.options.classPrefix}-button-text`);
1411
+ if (styleButtonText) styleButtonText.remove();
1412
+
1413
+ // Resize option
1414
+ const resizeOption = createMenuItem({
1415
+ classPrefix: this.player.options.classPrefix,
1416
+ itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1417
+ icon: 'resize',
1418
+ label: 'transcript.enableResizeMode',
1419
+ hasTextClass: true,
1420
+ onClick: (event) => {
1421
+ event.preventDefault();
1422
+ event.stopPropagation();
1423
+
1424
+ const enabled = this.toggleResizeMode({ focus: false });
1425
+
1426
+ if (enabled) {
1427
+ this.hideSettingsMenu({ focusButton: false });
1428
+ // Focus transcript window after handles appear
1429
+ setTimeout(() => {
1430
+ if (this.transcriptWindow) {
1431
+ this.transcriptWindow.focus({ preventScroll: true });
1432
+ }
1433
+ }, 20);
1434
+ } else {
1435
+ this.hideSettingsMenu({ focusButton: true });
1436
+ }
1437
+ }
1438
+ });
1439
+ resizeOption.setAttribute('role', 'switch');
1440
+ resizeOption.setAttribute('aria-checked', 'false');
1441
+ // Remove any tooltips from menu items (they have visible text)
1442
+ const resizeTooltip = resizeOption.querySelector(`.${this.player.options.classPrefix}-tooltip`);
1443
+ if (resizeTooltip) resizeTooltip.remove();
1444
+ const resizeButtonText = resizeOption.querySelector(`.${this.player.options.classPrefix}-button-text`);
1445
+ if (resizeButtonText) resizeButtonText.remove();
1446
+ this.resizeOptionButton = resizeOption;
1447
+ this.resizeOptionText = resizeOption.querySelector(`.${this.player.options.classPrefix}-settings-text`);
1448
+ this.updateResizeOptionState();
1449
+
1450
+ // Show timestamps option
1451
+ const showTimestampsOption = createMenuItem({
1452
+ classPrefix: this.player.options.classPrefix,
1453
+ itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1454
+ icon: 'clock',
1455
+ label: 'transcript.showTimestamps',
1456
+ hasTextClass: true,
1457
+ onClick: () => {
1458
+ this.toggleShowTimestamps();
1459
+ }
1460
+ });
1461
+ showTimestampsOption.setAttribute('role', 'switch');
1462
+ showTimestampsOption.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
1463
+ // Remove any tooltips from menu items (they have visible text)
1464
+ const timestampsTooltip = showTimestampsOption.querySelector(`.${this.player.options.classPrefix}-tooltip`);
1465
+ if (timestampsTooltip) timestampsTooltip.remove();
1466
+ const timestampsButtonText = showTimestampsOption.querySelector(`.${this.player.options.classPrefix}-button-text`);
1467
+ if (timestampsButtonText) timestampsButtonText.remove();
1468
+ this.showTimestampsButton = showTimestampsOption;
1469
+ this.showTimestampsText = showTimestampsOption.querySelector(`.${this.player.options.classPrefix}-settings-text`);
1470
+ this.updateShowTimestampsState();
1471
+
1472
+ // Close option
1473
+ const closeOption = createMenuItem({
1474
+ classPrefix: this.player.options.classPrefix,
1475
+ itemClass: `${this.player.options.classPrefix}-transcript-settings-item`,
1476
+ icon: 'close',
1477
+ label: 'transcript.closeMenu',
1478
+ onClick: () => {
1479
+ this.hideSettingsMenu();
1480
+ }
1481
+ });
1482
+ // Remove any tooltips from menu items (they have visible text)
1483
+ const closeTooltip = closeOption.querySelector(`.${this.player.options.classPrefix}-tooltip`);
1484
+ if (closeTooltip) closeTooltip.remove();
1485
+ const closeButtonText = closeOption.querySelector(`.${this.player.options.classPrefix}-button-text`);
1486
+ if (closeButtonText) closeButtonText.remove();
1487
+
1488
+ this.settingsMenu.appendChild(keyboardDragOption);
1489
+ this.settingsMenu.appendChild(resizeOption);
1490
+ this.settingsMenu.appendChild(styleOption);
1491
+ this.settingsMenu.appendChild(showTimestampsOption);
1492
+ this.settingsMenu.appendChild(closeOption);
1493
+
1494
+ // Position menu first (before it's visible) to prevent jumping
1495
+ // Set menu to invisible temporarily
1496
+ this.settingsMenu.style.visibility = 'hidden';
1497
+ this.settingsMenu.style.display = 'block';
1498
+
1499
+ // Insert menu right after settings button for proper positioning
1500
+ if (this.settingsButton && this.settingsButton.parentNode) {
1501
+ this.settingsButton.insertAdjacentElement('afterend', this.settingsMenu);
1502
+ } else if (this.headerLeft) {
1503
+ this.headerLeft.appendChild(this.settingsMenu);
1504
+ } else if (this.transcriptHeader) {
1505
+ this.transcriptHeader.appendChild(this.settingsMenu);
1506
+ } else {
1507
+ this.transcriptWindow.appendChild(this.settingsMenu);
1508
+ }
1509
+
1510
+ // Position the menu relative to the settings button (immediately while hidden)
1511
+ this.positionSettingsMenuImmediate();
1512
+
1513
+ // Make menu visible after positioning
1514
+ requestAnimationFrame(() => {
1515
+ if (this.settingsMenu) {
1516
+ this.settingsMenu.style.visibility = 'visible';
1517
+ }
1518
+ });
1519
+
1520
+ // Add keyboard navigation
1521
+ this.settingsMenuKeyHandler = attachMenuKeyboardNavigation(
1522
+ this.settingsMenu,
1523
+ this.settingsButton,
1524
+ `.${this.player.options.classPrefix}-transcript-settings-item`,
1525
+ () => this.hideSettingsMenu({ focusButton: true })
1526
+ );
1527
+
1528
+ // Set the menu as visible and display it
1529
+ this.settingsMenuVisible = true;
1530
+ this.settingsMenu.style.display = 'block';
1531
+
1532
+ // Update aria-expanded
1533
+ if (this.settingsButton) {
1534
+ this.settingsButton.setAttribute('aria-expanded', 'true');
1535
+ }
1536
+ this.updateResizeOptionState();
1537
+
1538
+ // Focus first menu item after visibility is set
1539
+ setTimeout(() => {
1540
+ const menuItems = this.settingsMenu.querySelectorAll(`.${this.player.options.classPrefix}-transcript-settings-item`);
1541
+ if (menuItems.length > 0) {
1542
+ menuItems[0].setAttribute('tabindex', '0');
1543
+ for (let i = 1; i < menuItems.length; i++) {
1544
+ menuItems[i].setAttribute('tabindex', '-1');
1545
+ }
1546
+ menuItems[0].focus({ preventScroll: true });
1547
+ }
1548
+ }, 50);
1549
+ }
1550
+
1551
+ /**
1552
+ * Position settings menu relative to settings button (immediate/synchronous)
1553
+ */
1554
+ positionSettingsMenuImmediate() {
1555
+ if (!this.settingsMenu || !this.settingsButton) return;
1556
+
1557
+ // Get the parent container (header-left) which has position: relative
1558
+ const container = this.settingsButton.parentElement;
1559
+ if (!container) return;
1560
+
1561
+ // Position immediately (synchronously) - used when menu is first shown
1562
+ const buttonRect = this.settingsButton.getBoundingClientRect();
1563
+ const containerRect = container.getBoundingClientRect();
1564
+ const menuRect = this.settingsMenu.getBoundingClientRect();
1565
+ const viewportHeight = window.innerHeight;
1566
+
1567
+ // Calculate position relative to the container
1568
+ const buttonLeft = buttonRect.left - containerRect.left;
1569
+ const buttonBottom = buttonRect.bottom - containerRect.top;
1570
+ const buttonTop = buttonRect.top - containerRect.top;
1571
+
1572
+ const spaceBelow = viewportHeight - buttonRect.bottom;
1573
+ const spaceAbove = buttonRect.top;
1574
+
1575
+ // Position menu below button by default (left-aligned with button)
1576
+ let menuTop = buttonBottom + 4;
1577
+
1578
+ // Check if we should position above instead
1579
+ if (spaceBelow < menuRect.height + 20 && spaceAbove > spaceBelow) {
1580
+ // Position above the button
1581
+ menuTop = buttonTop - menuRect.height - 4;
1582
+ this.settingsMenu.classList.add('vidply-menu-above');
1583
+ } else {
1584
+ this.settingsMenu.classList.remove('vidply-menu-above');
1585
+ }
1586
+
1587
+ // Apply positions (left-aligned with button)
1588
+ this.settingsMenu.style.top = `${menuTop}px`;
1589
+ this.settingsMenu.style.left = `${buttonLeft}px`;
1590
+ this.settingsMenu.style.right = 'auto';
1591
+ this.settingsMenu.style.bottom = 'auto';
1592
+ }
1593
+
1594
+ /**
1595
+ * Position settings menu relative to settings button (async for repositioning)
1596
+ */
1597
+ positionSettingsMenu() {
1598
+ if (!this.settingsMenu || !this.settingsButton) return;
1599
+
1600
+ // Use requestAnimationFrame to ensure layout is stable before positioning (for repositioning)
1601
+ requestAnimationFrame(() => {
1602
+ setTimeout(() => {
1603
+ this.positionSettingsMenuImmediate();
1604
+ }, 10); // Small delay to ensure layout is stable
1605
+ });
1606
+ }
1607
+
1608
+ /**
1609
+ * Attach keyboard navigation to settings menu
1610
+ */
1611
+ attachSettingsMenuKeyboardNavigation() {
1612
+ if (!this.settingsMenu) return;
1613
+
1614
+ // Remove existing handler if any
1615
+ if (this.settingsMenuKeyHandler) {
1616
+ this.settingsMenu.removeEventListener('keydown', this.settingsMenuKeyHandler, true);
1617
+ }
1618
+
1619
+ const handler = attachMenuKeyboardNavigation(
1620
+ this.settingsMenu,
1621
+ this.settingsButton,
1622
+ `.${this.player.options.classPrefix}-transcript-settings-item`,
1623
+ () => this.hideSettingsMenu({ focusButton: true })
1624
+ );
1625
+
1626
+ // Store the handler reference
1627
+ this.settingsMenuKeyHandler = handler;
1628
+
1629
+ }
1630
+
1631
+ /**
1632
+ * Hide settings menu
1633
+ */
1634
+ hideSettingsMenu({ focusButton = true } = {}) {
1635
+ if (this.settingsMenu) {
1636
+ this.settingsMenu.style.display = 'none';
1637
+ this.settingsMenuVisible = false;
1638
+ this.settingsMenuJustOpened = false;
1639
+
1640
+ // Remove keyboard handler to prevent duplicate listeners
1641
+ if (this.settingsMenuKeyHandler) {
1642
+ this.settingsMenu.removeEventListener('keydown', this.settingsMenuKeyHandler, true);
1643
+ this.settingsMenuKeyHandler = null;
1644
+ }
1645
+
1646
+ // Update aria-expanded
1647
+ if (this.settingsButton) {
1648
+ this.settingsButton.setAttribute('aria-expanded', 'false');
1649
+ if (focusButton) {
1650
+ // Return focus to settings button
1651
+ this.settingsButton.focus({ preventScroll: true });
1652
+ }
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ /**
1658
+ * Enable move mode (gives visual feedback)
1659
+ */
1660
+ enableMoveMode() {
1661
+ this.hideResizeModeIndicator();
1662
+
1663
+ // Add visual feedback for move mode
1664
+ this.transcriptWindow.classList.add(`${this.player.options.classPrefix}-transcript-move-mode`);
1665
+
1666
+ // Show tooltip about keyboard drag option
1667
+ const tooltip = DOMUtils.createElement('div', {
1668
+ className: `${this.player.options.classPrefix}-transcript-move-tooltip`,
1669
+ textContent: 'Drag with mouse or press D for keyboard drag mode'
1670
+ });
1671
+ this.transcriptHeader.appendChild(tooltip);
1672
+
1673
+ // Remove after 2 seconds
1674
+ setTimeout(() => {
1675
+ this.transcriptWindow.classList.remove(`${this.player.options.classPrefix}-transcript-move-mode`);
1676
+ if (tooltip.parentNode) {
1677
+ tooltip.remove();
1678
+ }
1679
+ }, 2000);
1680
+ }
1681
+
1682
+ /**
1683
+ * Toggle resize mode
1684
+ */
1685
+ toggleResizeMode({ focus = true } = {}) {
1686
+ if (!this.draggableResizable) {
1687
+ return false;
1688
+ }
1689
+
1690
+ if (this.draggableResizable.pointerResizeMode) {
1691
+ this.draggableResizable.disablePointerResizeMode({ focus });
1692
+ return false;
1693
+ }
1694
+
1695
+ this.draggableResizable.enablePointerResizeMode({ focus });
1696
+ return true;
1697
+ }
1698
+
1699
+ updateDragOptionState() {
1700
+ if (!this.dragOptionButton) {
1701
+ return;
1702
+ }
1703
+
1704
+ const isEnabled = !!(this.draggableResizable && this.draggableResizable.keyboardDragMode);
1705
+ const text = isEnabled
1706
+ ? i18n.t('transcript.disableDragMode')
1707
+ : i18n.t('transcript.enableDragMode');
1708
+ const ariaLabel = isEnabled
1709
+ ? i18n.t('transcript.disableDragModeAria')
1710
+ : i18n.t('transcript.enableDragModeAria');
1711
+
1712
+ this.dragOptionButton.setAttribute('aria-checked', isEnabled ? 'true' : 'false');
1713
+ this.dragOptionButton.setAttribute('aria-label', ariaLabel);
1714
+
1715
+ if (this.dragOptionText) {
1716
+ this.dragOptionText.textContent = text;
1717
+ }
1718
+ }
1719
+
1720
+ updateResizeOptionState() {
1721
+ if (!this.resizeOptionButton) {
1722
+ return;
1723
+ }
1724
+
1725
+ const isEnabled = !!(this.draggableResizable && this.draggableResizable.pointerResizeMode);
1726
+ const text = isEnabled
1727
+ ? i18n.t('transcript.disableResizeMode')
1728
+ : i18n.t('transcript.enableResizeMode');
1729
+ const ariaLabel = isEnabled
1730
+ ? i18n.t('transcript.disableResizeModeAria')
1731
+ : i18n.t('transcript.enableResizeModeAria');
1732
+
1733
+ this.resizeOptionButton.setAttribute('aria-checked', isEnabled ? 'true' : 'false');
1734
+ this.resizeOptionButton.setAttribute('aria-label', ariaLabel);
1735
+
1736
+ if (this.resizeOptionText) {
1737
+ this.resizeOptionText.textContent = text;
1738
+ }
1739
+ }
1740
+
1741
+ toggleShowTimestamps() {
1742
+ this.showTimestamps = !this.showTimestamps;
1743
+ this.updateShowTimestampsState();
1744
+ this.updateTimestampVisibility();
1745
+ this.saveTimestampsPreference();
1746
+ }
1747
+
1748
+ updateShowTimestampsState() {
1749
+ if (!this.showTimestampsButton) {
1750
+ return;
1751
+ }
1752
+
1753
+ const text = this.showTimestamps
1754
+ ? i18n.t('transcript.hideTimestamps')
1755
+ : i18n.t('transcript.showTimestamps');
1756
+ const ariaLabel = this.showTimestamps
1757
+ ? i18n.t('transcript.hideTimestampsAria')
1758
+ : i18n.t('transcript.showTimestampsAria');
1759
+
1760
+ this.showTimestampsButton.setAttribute('aria-checked', this.showTimestamps ? 'true' : 'false');
1761
+ this.showTimestampsButton.setAttribute('aria-label', ariaLabel);
1762
+
1763
+ if (this.showTimestampsText) {
1764
+ this.showTimestampsText.textContent = text;
1765
+ }
1766
+ }
1767
+
1768
+ updateTimestampVisibility() {
1769
+ if (!this.transcriptContent) return;
1770
+
1771
+ const timestamps = this.transcriptContent.querySelectorAll(`.${this.player.options.classPrefix}-transcript-time`);
1772
+ timestamps.forEach(timestamp => {
1773
+ timestamp.style.display = this.showTimestamps ? '' : 'none';
1774
+ });
1775
+ }
1776
+
1777
+ saveTimestampsPreference() {
1778
+ const savedPreferences = this.storage.getTranscriptPreferences() || {};
1779
+ savedPreferences.showTimestamps = this.showTimestamps;
1780
+ this.storage.saveTranscriptPreferences(savedPreferences);
1781
+ }
1782
+
1783
+ showResizeModeIndicator() {
1784
+ if (!this.transcriptHeader) {
1785
+ return;
1786
+ }
1787
+
1788
+ this.hideResizeModeIndicator();
1789
+
1790
+ const indicator = DOMUtils.createElement('div', {
1791
+ className: `${this.player.options.classPrefix}-transcript-resize-tooltip`,
1792
+ textContent: i18n.t('transcript.resizeModeHint') || 'Resize handles enabled. Drag edges or corners to adjust. Press Esc or R to exit.'
1793
+ });
1794
+
1795
+ this.transcriptHeader.appendChild(indicator);
1796
+ this.resizeModeIndicator = indicator;
1797
+
1798
+ this.resizeModeIndicatorTimeout = this.setManagedTimeout(() => {
1799
+ this.hideResizeModeIndicator();
1800
+ }, 3000);
1801
+ }
1802
+
1803
+ hideResizeModeIndicator() {
1804
+ if (this.resizeModeIndicatorTimeout) {
1805
+ this.clearManagedTimeout(this.resizeModeIndicatorTimeout);
1806
+ this.resizeModeIndicatorTimeout = null;
1807
+ }
1808
+
1809
+ if (this.resizeModeIndicator && this.resizeModeIndicator.parentNode) {
1810
+ this.resizeModeIndicator.remove();
1811
+ }
1812
+
1813
+ this.resizeModeIndicator = null;
1814
+ }
1815
+
1816
+ onPointerResizeModeChange(enabled) {
1817
+ this.updateResizeOptionState();
1818
+
1819
+ if (enabled) {
1820
+ this.showResizeModeIndicator();
1821
+ this.announceLive(i18n.t('transcript.resizeModeEnabled'));
1822
+ } else {
1823
+ this.hideResizeModeIndicator();
1824
+ this.announceLive(i18n.t('transcript.resizeModeDisabled'));
1825
+ }
1826
+ }
1827
+
1828
+ /**
1829
+ * Show style dialog
1830
+ */
1831
+ showStyleDialog() {
1832
+ // If dialog already exists, just show it
1833
+ if (this.styleDialog) {
1834
+ this.styleDialog.style.display = 'block';
1835
+ this.styleDialogVisible = true;
1836
+
1837
+ // Re-add keyboard handler
1838
+ if (this.handlers.styleDialogKeydown) {
1839
+ document.addEventListener('keydown', this.handlers.styleDialogKeydown);
1840
+ }
1841
+
1842
+ // Set flag to prevent immediate closing from document click
1843
+ this.styleDialogJustOpened = true;
1844
+ setTimeout(() => {
1845
+ this.styleDialogJustOpened = false;
1846
+ }, 350);
1847
+
1848
+ // Focus first control
1849
+ setTimeout(() => {
1850
+ const firstSelect = this.styleDialog.querySelector('select, input');
1851
+ if (firstSelect) {
1852
+ firstSelect.focus({ preventScroll: true });
1853
+ }
1854
+ }, 0);
1855
+ return;
1856
+ }
1857
+
1858
+ // Create style dialog
1859
+ this.styleDialog = DOMUtils.createElement('div', {
1860
+ className: `${this.player.options.classPrefix}-transcript-style-dialog`
1861
+ });
1862
+
1863
+ // Dialog title
1864
+ const title = DOMUtils.createElement('h4', {
1865
+ textContent: i18n.t('transcript.styleTitle'),
1866
+ className: `${this.player.options.classPrefix}-transcript-style-title`
1867
+ });
1868
+ this.styleDialog.appendChild(title);
1869
+
1870
+ // Font Size
1871
+ const fontSizeControl = this.createStyleSelectControl(
1872
+ i18n.t('captions.fontSize'),
1873
+ 'fontSize',
1874
+ [
1875
+ { label: i18n.t('fontSizes.small'), value: '90%' },
1876
+ { label: i18n.t('fontSizes.normal'), value: '100%' },
1877
+ { label: i18n.t('fontSizes.large'), value: '110%' },
1878
+ { label: i18n.t('fontSizes.xlarge'), value: '120%' }
1879
+ ]
1880
+ );
1881
+ this.styleDialog.appendChild(fontSizeControl);
1882
+
1883
+ // Font Family
1884
+ const fontFamilyControl = this.createStyleSelectControl(
1885
+ i18n.t('captions.fontFamily'),
1886
+ 'fontFamily',
1887
+ [
1888
+ { label: i18n.t('fontFamilies.sansSerif'), value: 'sans-serif' },
1889
+ { label: i18n.t('fontFamilies.serif'), value: 'serif' },
1890
+ { label: i18n.t('fontFamilies.monospace'), value: 'monospace' }
1891
+ ]
1892
+ );
1893
+ this.styleDialog.appendChild(fontFamilyControl);
1894
+
1895
+ // Text Color
1896
+ const colorControl = this.createStyleColorControl(i18n.t('captions.color'), 'color');
1897
+ this.styleDialog.appendChild(colorControl);
1898
+
1899
+ // Background Color
1900
+ const bgColorControl = this.createStyleColorControl(i18n.t('captions.backgroundColor'), 'backgroundColor');
1901
+ this.styleDialog.appendChild(bgColorControl);
1902
+
1903
+ // Opacity
1904
+ const opacityControl = this.createStyleOpacityControl(i18n.t('captions.opacity'), 'opacity');
1905
+ this.styleDialog.appendChild(opacityControl);
1906
+
1907
+ // Close button
1908
+ const closeBtn = DOMUtils.createElement('button', {
1909
+ className: `${this.player.options.classPrefix}-transcript-style-close`,
1910
+ textContent: i18n.t('settings.close'),
1911
+ attributes: {
1912
+ 'type': 'button'
1913
+ }
1914
+ });
1915
+ closeBtn.addEventListener('click', () => this.hideStyleDialog());
1916
+ this.styleDialog.appendChild(closeBtn);
1917
+
1918
+ // Keyboard navigation for style dialog
1919
+ this.handlers.styleDialogKeydown = (e) => {
1920
+ // Only handle keys when dialog is visible
1921
+ if (!this.styleDialogVisible) return;
1922
+
1923
+ // ESC to close
1924
+ if (e.key === 'Escape') {
1925
+ e.preventDefault();
1926
+ e.stopPropagation();
1927
+ this.hideStyleDialog();
1928
+ return;
1929
+ }
1930
+
1931
+ // Tab navigation (allow default behavior but trap focus)
1932
+ if (e.key === 'Tab') {
1933
+ // Get all focusable elements
1934
+ const focusableElements = this.styleDialog.querySelectorAll(
1935
+ 'select, input, button'
1936
+ );
1937
+ const firstElement = focusableElements[0];
1938
+ const lastElement = focusableElements[focusableElements.length - 1];
1939
+
1940
+ // Trap focus within dialog
1941
+ if (e.shiftKey && document.activeElement === firstElement) {
1942
+ e.preventDefault();
1943
+ lastElement.focus({ preventScroll: true });
1944
+ } else if (!e.shiftKey && document.activeElement === lastElement) {
1945
+ e.preventDefault();
1946
+ firstElement.focus({ preventScroll: true });
1947
+ }
1948
+ }
1949
+ };
1950
+ document.addEventListener('keydown', this.handlers.styleDialogKeydown);
1951
+
1952
+ // Append to header left container (same as settings menu) for correct positioning
1953
+ if (this.headerLeft) {
1954
+ this.headerLeft.appendChild(this.styleDialog);
1955
+ } else {
1956
+ this.transcriptHeader.appendChild(this.styleDialog);
1957
+ }
1958
+
1959
+ // Apply current styles
1960
+ this.applyTranscriptStyles();
1961
+
1962
+ // Important: Set visible state and display before focusing
1963
+ this.styleDialogVisible = true;
1964
+ this.styleDialog.style.display = 'block';
1965
+
1966
+ // Set flag to prevent immediate closing from document click
1967
+ this.styleDialogJustOpened = true;
1968
+ setTimeout(() => {
1969
+ this.styleDialogJustOpened = false;
1970
+ }, 350);
1971
+
1972
+ // Focus first control for keyboard accessibility
1973
+ setTimeout(() => {
1974
+ const firstSelect = this.styleDialog.querySelector('select, input');
1975
+ if (firstSelect) {
1976
+ firstSelect.focus({ preventScroll: true });
1977
+ }
1978
+ }, 0);
1979
+ }
1980
+
1981
+ /**
1982
+ * Hide style dialog
1983
+ */
1984
+ hideStyleDialog() {
1985
+ if (this.styleDialog) {
1986
+ this.styleDialog.style.display = 'none';
1987
+ this.styleDialogVisible = false;
1988
+
1989
+ // Remove keyboard handler
1990
+ if (this.handlers.styleDialogKeydown) {
1991
+ document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
1992
+ }
1993
+
1994
+ // Return focus to settings button
1995
+ if (this.settingsButton) {
1996
+ this.settingsButton.focus({ preventScroll: true });
1997
+ }
1998
+ }
1999
+ }
2000
+
2001
+ /**
2002
+ * Create style select control
2003
+ */
2004
+ createStyleSelectControl(label, property, options) {
2005
+ const group = DOMUtils.createElement('div', {
2006
+ className: `${this.player.options.classPrefix}-transcript-style-group`
2007
+ });
2008
+
2009
+ // Generate unique ID for the control
2010
+ const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2011
+
2012
+ const labelEl = DOMUtils.createElement('label', {
2013
+ textContent: label,
2014
+ attributes: {
2015
+ 'for': controlId
2016
+ }
2017
+ });
2018
+ group.appendChild(labelEl);
2019
+
2020
+ const select = DOMUtils.createElement('select', {
2021
+ className: `${this.player.options.classPrefix}-transcript-style-select`,
2022
+ attributes: {
2023
+ 'id': controlId
2024
+ }
2025
+ });
2026
+
2027
+ options.forEach(opt => {
2028
+ const option = DOMUtils.createElement('option', {
2029
+ textContent: opt.label,
2030
+ attributes: {
2031
+ 'value': opt.value
2032
+ }
2033
+ });
2034
+ if (this.transcriptStyle[property] === opt.value) {
2035
+ option.selected = true;
2036
+ }
2037
+ select.appendChild(option);
2038
+ });
2039
+
2040
+ select.addEventListener('change', (e) => {
2041
+ this.transcriptStyle[property] = e.target.value;
2042
+ this.applyTranscriptStyles();
2043
+ this.savePreferences();
2044
+ });
2045
+
2046
+ group.appendChild(select);
2047
+ return group;
2048
+ }
2049
+
2050
+ /**
2051
+ * Create style color control
2052
+ */
2053
+ createStyleColorControl(label, property) {
2054
+ const group = DOMUtils.createElement('div', {
2055
+ className: `${this.player.options.classPrefix}-transcript-style-group`
2056
+ });
2057
+
2058
+ // Generate unique ID for the control
2059
+ const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2060
+
2061
+ const labelEl = DOMUtils.createElement('label', {
2062
+ textContent: label,
2063
+ attributes: {
2064
+ 'for': controlId
2065
+ }
2066
+ });
2067
+ group.appendChild(labelEl);
2068
+
2069
+ const input = DOMUtils.createElement('input', {
2070
+ attributes: {
2071
+ 'id': controlId,
2072
+ 'type': 'color',
2073
+ 'value': this.transcriptStyle[property]
2074
+ },
2075
+ className: `${this.player.options.classPrefix}-transcript-style-color`
2076
+ });
2077
+
2078
+ input.addEventListener('input', (e) => {
2079
+ this.transcriptStyle[property] = e.target.value;
2080
+ this.applyTranscriptStyles();
2081
+ this.savePreferences();
2082
+ });
2083
+
2084
+ group.appendChild(input);
2085
+ return group;
2086
+ }
2087
+
2088
+ /**
2089
+ * Create style opacity control
2090
+ */
2091
+ createStyleOpacityControl(label, property) {
2092
+ const group = DOMUtils.createElement('div', {
2093
+ className: `${this.player.options.classPrefix}-transcript-style-group`
2094
+ });
2095
+
2096
+ // Generate unique ID for the control
2097
+ const controlId = `${this.player.options.classPrefix}-transcript-${property}-${Date.now()}`;
2098
+
2099
+ const labelEl = DOMUtils.createElement('label', {
2100
+ textContent: label,
2101
+ attributes: {
2102
+ 'for': controlId
2103
+ }
2104
+ });
2105
+ group.appendChild(labelEl);
2106
+
2107
+ const valueDisplay = DOMUtils.createElement('span', {
2108
+ textContent: Math.round(this.transcriptStyle[property] * 100) + '%',
2109
+ className: `${this.player.options.classPrefix}-transcript-style-value`
2110
+ });
2111
+
2112
+ const input = DOMUtils.createElement('input', {
2113
+ attributes: {
2114
+ 'id': controlId,
2115
+ 'type': 'range',
2116
+ 'min': '0',
2117
+ 'max': '1',
2118
+ 'step': '0.1',
2119
+ 'value': String(this.transcriptStyle[property])
2120
+ },
2121
+ className: `${this.player.options.classPrefix}-transcript-style-range`
2122
+ });
2123
+
2124
+ input.addEventListener('input', (e) => {
2125
+ const value = parseFloat(e.target.value);
2126
+ this.transcriptStyle[property] = value;
2127
+ valueDisplay.textContent = Math.round(value * 100) + '%';
2128
+ this.applyTranscriptStyles();
2129
+ this.savePreferences();
2130
+ });
2131
+
2132
+ const inputContainer = DOMUtils.createElement('div', {
2133
+ className: `${this.player.options.classPrefix}-transcript-style-range-container`
2134
+ });
2135
+ inputContainer.appendChild(input);
2136
+ inputContainer.appendChild(valueDisplay);
2137
+
2138
+ group.appendChild(labelEl);
2139
+ group.appendChild(inputContainer);
2140
+ return group;
2141
+ }
2142
+
2143
+ /**
2144
+ * Save transcript preferences to localStorage
2145
+ */
2146
+ savePreferences() {
2147
+ this.storage.saveTranscriptPreferences(this.transcriptStyle);
2148
+ }
2149
+
2150
+ /**
2151
+ * Apply transcript styles
2152
+ */
2153
+ applyTranscriptStyles() {
2154
+ if (!this.transcriptWindow) return;
2155
+
2156
+ // Apply to transcript window background
2157
+ this.transcriptWindow.style.backgroundColor = this.transcriptStyle.backgroundColor;
2158
+ this.transcriptWindow.style.opacity = String(this.transcriptStyle.opacity);
2159
+
2160
+ // Apply to content area
2161
+ if (this.transcriptContent) {
2162
+ this.transcriptContent.style.fontSize = this.transcriptStyle.fontSize;
2163
+ this.transcriptContent.style.fontFamily = this.transcriptStyle.fontFamily;
2164
+ this.transcriptContent.style.color = this.transcriptStyle.color;
2165
+ }
2166
+
2167
+ // Apply to all text entries (important: override CSS defaults)
2168
+ const textEntries = this.transcriptWindow.querySelectorAll(`.${this.player.options.classPrefix}-transcript-text`);
2169
+ textEntries.forEach(entry => {
2170
+ entry.style.fontSize = this.transcriptStyle.fontSize;
2171
+ entry.style.fontFamily = this.transcriptStyle.fontFamily;
2172
+ entry.style.color = this.transcriptStyle.color;
2173
+ });
2174
+
2175
+ // Apply to timestamp entries as well
2176
+ const timeEntries = this.transcriptWindow.querySelectorAll(`.${this.player.options.classPrefix}-transcript-time`);
2177
+ timeEntries.forEach(entry => {
2178
+ entry.style.fontFamily = this.transcriptStyle.fontFamily;
2179
+ });
2180
+ }
2181
+
2182
+ /**
2183
+ * Set a managed timeout that will be cleaned up on destroy
2184
+ * @param {Function} callback - Callback function
2185
+ * @param {number} delay - Delay in milliseconds
2186
+ * @returns {number} Timeout ID
2187
+ */
2188
+ setManagedTimeout(callback, delay) {
2189
+ const timeoutId = setTimeout(() => {
2190
+ this.timeouts.delete(timeoutId);
2191
+ callback();
2192
+ }, delay);
2193
+ this.timeouts.add(timeoutId);
2194
+ return timeoutId;
2195
+ }
2196
+
2197
+ /**
2198
+ * Clear a managed timeout
2199
+ * @param {number} timeoutId - Timeout ID to clear
2200
+ */
2201
+ clearManagedTimeout(timeoutId) {
2202
+ if (timeoutId) {
2203
+ clearTimeout(timeoutId);
2204
+ this.timeouts.delete(timeoutId);
2205
+ }
2206
+ }
2207
+
2208
+ /**
2209
+ * Cleanup
2210
+ */
2211
+ destroy() {
2212
+ this.hideResizeModeIndicator();
2213
+
2214
+ // Destroy draggableResizable utility
2215
+ if (this.draggableResizable) {
2216
+ if (this.draggableResizable.pointerResizeMode) {
2217
+ this.draggableResizable.disablePointerResizeMode();
2218
+ this.updateResizeOptionState();
2219
+ }
2220
+ this.draggableResizable.destroy();
2221
+ this.draggableResizable = null;
2222
+ }
2223
+
2224
+ // Remove custom key handler
2225
+ if (this.transcriptWindow && this.customKeyHandler) {
2226
+ this.transcriptWindow.removeEventListener('keydown', this.customKeyHandler);
2227
+ this.customKeyHandler = null;
2228
+ }
2229
+
2230
+ // Remove timeupdate listener from player
2231
+ if (this.handlers.timeupdate) {
2232
+ this.player.off('timeupdate', this.handlers.timeupdate);
2233
+ }
2234
+
2235
+ // Remove audio description listeners from player
2236
+ if (this.handlers.audiodescriptionenabled) {
2237
+ this.player.off('audiodescriptionenabled', this.handlers.audiodescriptionenabled);
2238
+ }
2239
+ if (this.handlers.audiodescriptiondisabled) {
2240
+ this.player.off('audiodescriptiondisabled', this.handlers.audiodescriptiondisabled);
2241
+ }
2242
+
2243
+ // Remove settings button event listeners
2244
+ if (this.settingsButton) {
2245
+ if (this.handlers.settingsClick) {
2246
+ this.settingsButton.removeEventListener('click', this.handlers.settingsClick);
2247
+ }
2248
+ if (this.handlers.settingsKeydown) {
2249
+ this.settingsButton.removeEventListener('keydown', this.handlers.settingsKeydown);
2250
+ }
2251
+ }
2252
+
2253
+ // Remove style dialog event listeners
2254
+ if (this.handlers.styleDialogKeydown) {
2255
+ document.removeEventListener('keydown', this.handlers.styleDialogKeydown);
2256
+ }
2257
+
2258
+ // Remove document click listener
2259
+ if (this.handlers.documentClick) {
2260
+ document.removeEventListener('click', this.handlers.documentClick);
2261
+ }
2262
+
2263
+ // Remove window-level listeners
2264
+ if (this.handlers.resize) {
2265
+ window.removeEventListener('resize', this.handlers.resize);
2266
+ }
2267
+
2268
+ // Cleanup all managed timeouts
2269
+ this.timeouts.forEach(timeoutId => clearTimeout(timeoutId));
2270
+ this.timeouts.clear();
2271
+
2272
+ // Clear handlers
2273
+ this.handlers = null;
2274
+
2275
+ // Remove DOM element
2276
+ if (this.transcriptWindow && this.transcriptWindow.parentNode) {
2277
+ this.transcriptWindow.parentNode.removeChild(this.transcriptWindow);
2278
+ }
2279
+
2280
+ this.transcriptWindow = null;
2281
+ this.transcriptHeader = null;
2282
+ this.transcriptContent = null;
2283
+ this.transcriptEntries = [];
2284
+ this.settingsMenu = null;
2285
+ this.styleDialog = null;
2286
+ this.transcriptResizeHandles = [];
2287
+ this.resizeOptionButton = null;
2288
+ this.resizeOptionText = null;
2289
+ this.liveRegion = null;
2290
+ }
2291
+
2292
+ announceLive(message) {
2293
+ if (!this.liveRegion) return;
2294
+ this.liveRegion.textContent = message || '';
2295
+ }
2296
+ }